From b04b333db7a20455ab7420b60c9c178b038348f1 Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Mon, 25 May 2026 22:42:11 +0200 Subject: [PATCH] Add runtime event scope processing --- docs/runtime-derived-projections.md | 103 +++ docs/runtime-event-processing.md | 246 ++++++ ...ntime-tachograph-esper-scope-processing.md | 49 ++ ...e-event-processing.postman_collection.json | 164 ++++ .../UnifiedRuntimeProcessingController.java | 72 +- ...fiedRuntimeProcessingExceptionHandler.java | 6 +- ...fiedRuntimeDerivedProjectionResultDto.java | 22 + .../UnifiedRuntimeProcessingApiRequest.java | 17 +- ...dRuntimeTachographEsperScopeResultDto.java | 58 ++ .../RuntimeEventProcessingService.java | 26 + .../RuntimeEventPartitioningApiRequest.java | 46 ++ .../dto/RuntimeEventProcessingApiRequest.java | 48 ++ ...timeEventProcessingPartitionResultDto.java | 17 + ...meEventProcessingProfileDescriptorDto.java | 39 + .../dto/RuntimeEventProcessingResultDto.java | 29 + .../RuntimeEventPartitioningStrategy.java | 10 + .../RuntimeEventScopeClassifier.java | 73 ++ .../partition/RuntimeEventScopeType.java | 9 + .../RuntimeEventProcessingProfile.java | 37 + ...RuntimeEventProcessingProfileRegistry.java | 49 ++ ...verEsperRuntimeEventProcessingProfile.java | 225 ++++++ ...DriverVehicleEvidenceAttachmentResult.java | 24 + .../model/UnifiedDriverEventsRequest.java | 7 +- .../UnifiedRuntimeProcessingRequest.java | 209 ++++- ...riverVehicleEvidenceAttachmentService.java | 302 +++++++ ...chographFileSessionRuntimeEventLoader.java | 81 +- ...phFileSessionUnifiedDriverEventSource.java | 6 + ...nifiedRuntimeDerivedProjectionService.java | 472 +++++++++++ .../UnifiedRuntimeEventAssemblyService.java | 9 + ...TachographEsperScopeProcessingService.java | 293 +++++++ ...iverTimelineReusableProjectionBuilder.java | 112 ++- .../TachographEsperProcessingCore.java | 760 ++++++++++++++++++ .../TachographEsperProcessingInput.java | 88 ++ ...achographFileSessionProcessingService.java | 184 +---- ...nifiedRuntimeProcessingControllerTest.java | 292 ++++++- .../RuntimeEventProcessingServiceTest.java | 95 +++ ...imeEventProcessingProfileRegistryTest.java | 68 ++ ...sperRuntimeEventProcessingProfileTest.java | 149 ++++ .../UnifiedRuntimeProcessingRequestTest.java | 128 +++ ...rVehicleEvidenceAttachmentServiceTest.java | 205 +++++ ...raphFileSessionRuntimeEventLoaderTest.java | 120 ++- ...ifiedRuntimeDriverTimelineServiceTest.java | 6 + ...nifiedRuntimeEventAssemblyServiceTest.java | 6 + ...chographEsperProcessingCoreParityTest.java | 173 ++++ 44 files changed, 4927 insertions(+), 207 deletions(-) create mode 100644 docs/runtime-derived-projections.md create mode 100644 docs/runtime-event-processing.md create mode 100644 docs/runtime-tachograph-esper-scope-processing.md create mode 100644 postman/eventhub-runtime-event-processing.postman_collection.json create mode 100644 src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeDerivedProjectionResultDto.java create mode 100644 src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeTachographEsperScopeResultDto.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingService.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventPartitioningApiRequest.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingApiRequest.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingPartitionResultDto.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingProfileDescriptorDto.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingResultDto.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventPartitioningStrategy.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventScopeClassifier.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventScopeType.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfile.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfileRegistry.java create mode 100644 src/main/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfile.java create mode 100644 src/main/java/at/procon/eventhub/processing/model/RuntimeDriverVehicleEvidenceAttachmentResult.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeDerivedProjectionService.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeTachographEsperScopeProcessingService.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCore.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingInput.java create mode 100644 src/test/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingServiceTest.java create mode 100644 src/test/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfileRegistryTest.java create mode 100644 src/test/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfileTest.java create mode 100644 src/test/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentServiceTest.java create mode 100644 src/test/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCoreParityTest.java diff --git a/docs/runtime-derived-projections.md b/docs/runtime-derived-projections.md new file mode 100644 index 0000000..cf2711e --- /dev/null +++ b/docs/runtime-derived-projections.md @@ -0,0 +1,103 @@ +# Runtime-derived tachograph projections + +Runtime Processing now exposes the tachograph driving-derived Esper bundle through the unified runtime event assembly layer. + +## Endpoint + +```http +POST /api/eventhub/runtime-processing/driver-derived-projections +``` + +The request body uses the same selector fields as the existing runtime endpoints: + +- `sessionId` for one uploaded tachograph file session +- `sessionIds` for multiple uploaded tachograph file sessions +- `compositeSessionId` for an existing tachograph composite session +- `tenantKey` + driver selector for tachograph DB / YellowFox DB runtime sources +- `eventBackend` with `SOURCE_DB` or `EVENTHUB_DB` where supported +- `sourceFamilies`, for example `TACHOGRAPH_FILE_SESSION`, `TACHOGRAPH_DB`, `YELLOWFOX_DB` +- `driverKey`, `driverSourceEntityId`, `driverCardNation`, `driverCardNumber` +- `occurredFrom`, `occurredTo` +- `expandVehicleEvents` +- `vehicleExpansionPaddingMinutes` + +Additional Esper thresholds are optional: + +- `significantDrivingMinutes` +- `minimumRestPeriodMinutes` + +When omitted, the defaults are read from: + +```yaml +eventhub: + tachograph-file-session: + processing: + significant-driving-minutes: 3 + minimum-rest-period-minutes: 720 +``` + +## Flow + +```text +runtime request + -> UnifiedRuntimeEventAssemblyService + -> driver seed events + -> discovered vehicles + -> optional vehicle-expanded events + -> merged runtime event stream + -> UnifiedEventTimelineReconstructor + -> DriverTimelineReusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundleFromEvents(...) + -> TachographEsperDriverProcessingResultDto +``` + +The derived part always uses the event-input Esper path. This means the runtime stream is passed as point events to Esper, where activity and card-vehicle-usage intervals are paired and vehicle-usage intervals are merged before the existing driving-derived rules run. + +## Example: composite tachograph file session + +```json +{ + "compositeSessionId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "sourceFamilies": ["TACHOGRAPH_FILE_SESSION"], + "driverKey": "12:12345678901234", + "occurredFrom": "2026-05-01T00:00:00Z", + "occurredTo": "2026-05-31T23:59:59Z", + "expandVehicleEvents": true, + "vehicleExpansionPaddingMinutes": 15, + "significantDrivingMinutes": 3, + "minimumRestPeriodMinutes": 720 +} +``` + +## Response + +The response contains runtime assembly metadata and the tachograph Esper result: + +```text +request +driverSeedEventCount +discoveredVehicleCount +expandedVehicleEventCount +mergedEventCount +discoveredVehicles +projection +notes +``` + +`projection` is the same high-level structure returned by the tachograph file-session Esper endpoint, including: + +- activity interval count/list +- driving interval count/list +- driving interruption intervals +- daily/weekly rest candidate intervals +- daily/weekly rest candidate coverage intervals +- unclassified rest candidate coverage intervals +- potential home overnight stays +- potential in-vehicle overnight stays +- potential in-vehicle trips +- vehicle usage intervals +- VU card absent intervals +- support geo events + +## Boundary note + +Runtime processing works with point events. If `occurredFrom`/`occurredTo` cuts through a source interval, the matching START/END or INSERT/WITHDRAW point may be outside the selected window. For evaluations near boundaries, request a wider event window or use vehicle expansion padding so Esper receives enough point events to reconstruct the interval. diff --git a/docs/runtime-event-processing.md b/docs/runtime-event-processing.md new file mode 100644 index 0000000..842bf70 --- /dev/null +++ b/docs/runtime-event-processing.md @@ -0,0 +1,246 @@ +# Runtime Event Processing + +Runtime Processing is now source-neutral. The API receives a runtime scope, selects a processing profile, partitions the normalized EventHub-style events, and delegates execution to the selected profile. + +The tachograph Esper processing is no longer the root concept. It is one profile: + +```text +tachograph-driver-esper-v1 +``` + +## Profile discovery endpoint + +```http +GET /api/eventhub/runtime-processing/event-processing/profiles +``` + +Example response: + +```json +[ + { + "profileKey": "tachograph-driver-esper-v1", + "displayName": "Tachograph Driver Esper Processing", + "description": "Runs the shared tachograph driver Esper processing pipeline over Runtime Processing event scopes.", + "defaultPartitioningStrategy": "DRIVER", + "supportedPartitioningStrategies": ["DRIVER"], + "requiredParameters": [], + "optionalParameters": [ + "significantDrivingMinutes", + "minimumRestPeriodMinutes", + "attachVehicleOnlyEvents", + "vehicleEvidencePaddingMinutes" + ] + } +] +``` + +Clients should prefer this endpoint instead of hardcoding profile metadata. + +## Generic execution endpoint + +```http +POST /api/eventhub/runtime-processing/event-processing +``` + +## Request shape + +```json +{ + "profileKey": "tachograph-driver-esper-v1", + "scope": { + "sessionIds": [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222" + ], + "sourceFamilies": ["TACHOGRAPH_FILE_SESSION"], + "occurredFrom": "2026-05-01T00:00:00Z", + "occurredTo": "2026-05-31T23:59:59Z", + "expandVehicleEvents": true, + "vehicleExpansionPaddingMinutes": 15 + }, + "partitioning": { + "strategy": "DRIVER", + "includeAllPartitions": true, + "attachVehicleEvidence": true, + "vehicleEvidencePaddingMinutes": 15 + }, + "parameters": { + "significantDrivingMinutes": 3, + "minimumRestPeriodMinutes": 720, + "attachVehicleOnlyEvents": true, + "vehicleEvidencePaddingMinutes": 15 + } +} +``` + +## Response shape + +```json +{ + "profileKey": "tachograph-driver-esper-v1", + "partitioningStrategy": "DRIVER", + "inputEventCount": 1234, + "selectedPartitionCount": 2, + "discoveredVehicleCount": 3, + "partitionResults": { + "12:12345678901234": { + "partitionType": "DRIVER", + "partitionKey": "12:12345678901234", + "resultType": "UnifiedRuntimeDerivedProjectionResultDto", + "result": { + "projection": { + "activityIntervals": [], + "drivingIntervals": [], + "drivingInterruptionIntervals": [], + "dailyWeeklyRestCandidateIntervals": [], + "dailyWeeklyRestCandidateCoverageIntervals": [], + "unclassifiedDailyWeeklyRestCandidateCoverageIntervals": [], + "potentialHomeOvernightStayIntervals": [], + "potentialInVehicleOvernightStayIntervals": [], + "potentialInVehicleTripIntervals": [], + "vehicleUsageIntervals": [], + "vuCardAbsentIntervals": [], + "supportGeoEvents": [] + } + } + } + }, + "notes": [], + "warnings": [] +} +``` + +## Concepts + +### Scope + +`scope` is the existing runtime selection model. It can select events from: + +```text +TACHOGRAPH_FILE_SESSION +TACHOGRAPH_DB +YELLOWFOX_DB +``` + +and can use: + +```text +SOURCE_DB +EVENTHUB_DB +``` + +where supported. + +For uploaded tachograph files, the scope can use: + +```text +sessionId +sessionIds +compositeSessionId +``` + +### Profile + +A profile owns domain-specific processing semantics. It defines: + +```text +profile key +expected partitioning strategy +profile-specific parameters +result type +``` + +Current profile: + +```text +tachograph-driver-esper-v1 +``` + +Future profiles can include: + +```text +vehicle-stop-detection-v1 +vehicle-trip-detection-v1 +telematics-poi-clustering-v1 +driver-settlement-v1 +mixed-driver-vehicle-correlation-v1 +``` + +### Partitioning + +The common API supports generic partitioning options: + +```text +NONE +DRIVER +VEHICLE +DRIVER_VEHICLE +SOURCE_FAMILY +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. + +## Compatibility endpoint + +The old tachograph endpoint remains available: + +```http +POST /api/eventhub/runtime-processing/tachograph/esper-processing +``` + +It now acts as a compatibility adapter for: + +```http +POST /api/eventhub/runtime-processing/event-processing +``` + +with: + +```text +profileKey = tachograph-driver-esper-v1 +partitioning.strategy = DRIVER +``` + +## Tachograph profile processing flow + +```text +runtime event scope + -> broad event assembly + -> driver partition discovery + -> for each driver: + direct driver events + reconstruct driver vehicle-usage intervals + attach only vehicle-scoped events whose vehicle matches and whose timestamp falls inside a usage interval plus configured padding + shared event-input Esper projection pipeline + -> generic partitionResults map +``` + +The tachograph profile reuses the same `TachographEsperProcessingCore` used by the file-session endpoint. This prevents the file-session API and runtime-processing API from drifting into separate rule chains. +## Vehicle-only evidence attachment + +For driver-partitioned profiles, vehicle-only events are no longer attached only by vehicle identity. They are attached to a driver partition only when there is temporal evidence: + +```text +vehicle-only event.vehicleKey/registrationKey matches a reconstructed driver vehicle-usage interval +and event.occurredAt is inside [usage.from - padding, usage.to + padding] +``` + +This prevents unrelated vehicle events from being copied into a driver result simply because the driver used the same vehicle on another day. The tachograph profile currently uses: + +```json +{ + "partitioning": { + "attachVehicleEvidence": true, + "vehicleEvidencePaddingMinutes": 15 + }, + "parameters": { + "attachVehicleOnlyEvents": true, + "vehicleEvidencePaddingMinutes": 15 + } +} +``` + +`parameters` take precedence in the tachograph profile. The compatibility endpoint maps these values to `expandVehicleEvents` and `vehicleExpansionPaddingMinutes`. + diff --git a/docs/runtime-tachograph-esper-scope-processing.md b/docs/runtime-tachograph-esper-scope-processing.md new file mode 100644 index 0000000..668cfce --- /dev/null +++ b/docs/runtime-tachograph-esper-scope-processing.md @@ -0,0 +1,49 @@ +# Runtime tachograph Esper scope processing + +This document is kept for compatibility with earlier patches. + +The preferred endpoint is now the generic Runtime Event Processing endpoint: + +```http +POST /api/eventhub/runtime-processing/event-processing +``` + +Use the tachograph profile key: + +```text +tachograph-driver-esper-v1 +``` + +The old endpoint remains available as an adapter: + +```http +POST /api/eventhub/runtime-processing/tachograph/esper-processing +``` + +It delegates to the same profile infrastructure used by the generic endpoint. See: + +```text +docs/runtime-event-processing.md +``` + +Vehicle-only evidence is now attached through the same common runtime event-processing profile. The old compatibility endpoint maps: + +```json +{ + "expandVehicleEvents": true, + "vehicleExpansionPaddingMinutes": 15 +} +``` + +to the generic profile behavior: + +```json +{ + "parameters": { + "attachVehicleOnlyEvents": true, + "vehicleEvidencePaddingMinutes": 15 + } +} +``` + +Attachment is temporal: a vehicle-only event must match a reconstructed driver vehicle-usage interval and occur inside the interval plus configured padding. diff --git a/postman/eventhub-runtime-event-processing.postman_collection.json b/postman/eventhub-runtime-event-processing.postman_collection.json new file mode 100644 index 0000000..8e641b3 --- /dev/null +++ b/postman/eventhub-runtime-event-processing.postman_collection.json @@ -0,0 +1,164 @@ +{ + "info": { + "name": "EventHub Runtime Event Processing", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Examples for source-neutral Runtime Event Processing and the tachograph-driver-esper-v1 profile." + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8085" + }, + { + "key": "sessionId", + "value": "11111111-1111-1111-1111-111111111111" + }, + { + "key": "sessionId2", + "value": "22222222-2222-2222-2222-222222222222" + }, + { + "key": "compositeSessionId", + "value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }, + { + "key": "driverKey", + "value": "12:12345678901234" + } + ], + "item": [ + { + "name": "List runtime event-processing profiles", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing/profiles", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "runtime-processing", + "event-processing", + "profiles" + ] + } + } + }, + { + "name": "Execute tachograph profile - single session and one driver", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "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}" + }, + "url": { + "raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "runtime-processing", + "event-processing" + ] + } + } + }, + { + "name": "Execute tachograph profile - multiple sessions all drivers", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "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}" + }, + "url": { + "raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "runtime-processing", + "event-processing" + ] + } + } + }, + { + "name": "Execute tachograph profile - composite session all drivers", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "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}" + }, + "url": { + "raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "runtime-processing", + "event-processing" + ] + } + } + }, + { + "name": "Compatibility endpoint - tachograph esper processing", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sessionIds\": [\n \"{{sessionId}}\",\n \"{{sessionId2}}\"\n ],\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"includeAllDrivers\": true,\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}" + }, + "url": { + "raw": "{{baseUrl}}/api/eventhub/runtime-processing/tachograph/esper-processing", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "runtime-processing", + "tachograph", + "esper-processing" + ] + } + } + } + ] +} \ 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 8ef3227..b5545af 100644 --- a/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingController.java +++ b/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingController.java @@ -1,15 +1,26 @@ package at.procon.eventhub.processing.api; +import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto; import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.dto.UnifiedRuntimeTachographEsperScopeResultDto; +import at.procon.eventhub.processing.eventprocessing.RuntimeEventProcessingService; +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.model.UnifiedRuntimeEventBundle; +import at.procon.eventhub.processing.service.UnifiedRuntimeDerivedProjectionService; import at.procon.eventhub.processing.service.UnifiedRuntimeDriverTimelineService; import at.procon.eventhub.processing.service.UnifiedRuntimeEventAssemblyService; +import at.procon.eventhub.processing.service.UnifiedRuntimeTachographEsperScopeProcessingService; import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; @RestController @RequestMapping("/api/eventhub/runtime-processing") @@ -17,13 +28,31 @@ public class UnifiedRuntimeProcessingController { private final UnifiedRuntimeEventAssemblyService eventAssemblyService; private final UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService; + private final UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService; + private final UnifiedRuntimeTachographEsperScopeProcessingService tachographEsperScopeProcessingService; + private final RuntimeEventProcessingService runtimeEventProcessingService; public UnifiedRuntimeProcessingController( UnifiedRuntimeEventAssemblyService eventAssemblyService, - UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService + UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService, + UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService + ) { + this(eventAssemblyService, runtimeDriverTimelineService, runtimeDerivedProjectionService, null, null); + } + + @Autowired + public UnifiedRuntimeProcessingController( + UnifiedRuntimeEventAssemblyService eventAssemblyService, + UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService, + UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService, + UnifiedRuntimeTachographEsperScopeProcessingService tachographEsperScopeProcessingService, + RuntimeEventProcessingService runtimeEventProcessingService ) { this.eventAssemblyService = eventAssemblyService; this.runtimeDriverTimelineService = runtimeDriverTimelineService; + this.runtimeDerivedProjectionService = runtimeDerivedProjectionService; + this.tachographEsperScopeProcessingService = tachographEsperScopeProcessingService; + this.runtimeEventProcessingService = runtimeEventProcessingService; } @PostMapping("/driver-events") @@ -39,4 +68,45 @@ public class UnifiedRuntimeProcessingController { ) { return ResponseEntity.ok(runtimeDriverTimelineService.loadDriverTimeline(request.toRuntimeRequest())); } + + @PostMapping("/driver-derived-projections") + public ResponseEntity loadDriverDerivedProjections( + @RequestBody UnifiedRuntimeProcessingApiRequest request + ) { + return ResponseEntity.ok(runtimeDerivedProjectionService.loadDriverDerivedProjections(request)); + } + + @GetMapping("/event-processing/profiles") + public ResponseEntity> listEventProcessingProfiles() { + if (runtimeEventProcessingService == null) { + throw new IllegalStateException("Runtime event processing service is not configured."); + } + return ResponseEntity.ok(runtimeEventProcessingService.listProfiles()); + } + + @PostMapping("/event-processing") + public ResponseEntity runEventProcessing( + @RequestBody RuntimeEventProcessingApiRequest request + ) { + if (runtimeEventProcessingService == null) { + throw new IllegalStateException("Runtime event processing service is not configured."); + } + return ResponseEntity.ok(runtimeEventProcessingService.process(request)); + } + + @PostMapping("/tachograph/esper-processing") + public ResponseEntity runTachographEsperProcessing( + @RequestBody UnifiedRuntimeProcessingApiRequest request + ) { + if (runtimeEventProcessingService != null) { + RuntimeEventProcessingResultDto genericResult = runtimeEventProcessingService.process( + RuntimeEventProcessingApiRequest.tachographDriverEsper(request) + ); + return ResponseEntity.ok(UnifiedRuntimeTachographEsperScopeResultDto.fromGenericRuntimeEventProcessingResult(genericResult)); + } + if (tachographEsperScopeProcessingService == null) { + throw new IllegalStateException("Tachograph Esper scope processing service is not configured."); + } + return ResponseEntity.ok(tachographEsperScopeProcessingService.processScope(request)); + } } diff --git a/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingExceptionHandler.java b/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingExceptionHandler.java index acd7af6..8a37dfe 100644 --- a/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingExceptionHandler.java +++ b/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingExceptionHandler.java @@ -1,6 +1,8 @@ package at.procon.eventhub.processing.api; +import at.procon.eventhub.tachographfilesession.service.DriverNotFoundInCompositeSessionException; import at.procon.eventhub.tachographfilesession.service.DriverNotFoundInSessionException; +import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionNotFoundException; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionNotFoundException; import java.time.OffsetDateTime; import java.util.Map; @@ -14,7 +16,9 @@ public class UnifiedRuntimeProcessingExceptionHandler { @ExceptionHandler({ TachographFileSessionNotFoundException.class, - DriverNotFoundInSessionException.class + TachographCompositeSessionNotFoundException.class, + DriverNotFoundInSessionException.class, + DriverNotFoundInCompositeSessionException.class }) public ResponseEntity> notFound(RuntimeException exception) { return error(HttpStatus.NOT_FOUND, exception); diff --git a/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeDerivedProjectionResultDto.java b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeDerivedProjectionResultDto.java new file mode 100644 index 0000000..8fcb376 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeDerivedProjectionResultDto.java @@ -0,0 +1,22 @@ +package at.procon.eventhub.processing.dto; + +import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; +import java.util.List; + +public record UnifiedRuntimeDerivedProjectionResultDto( + UnifiedRuntimeProcessingRequest request, + int driverSeedEventCount, + int discoveredVehicleCount, + int expandedVehicleEventCount, + int mergedEventCount, + List discoveredVehicles, + TachographEsperDriverProcessingResultDto projection, + List notes +) { + public UnifiedRuntimeDerivedProjectionResultDto { + discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles); + notes = notes == null ? List.of() : List.copyOf(notes); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeProcessingApiRequest.java b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeProcessingApiRequest.java index f6cd3c7..3d6c3dc 100644 --- a/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeProcessingApiRequest.java +++ b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeProcessingApiRequest.java @@ -4,30 +4,45 @@ import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend; import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; import java.time.OffsetDateTime; +import java.util.List; import java.util.Set; import java.util.UUID; public record UnifiedRuntimeProcessingApiRequest( UUID sessionId, + List sessionIds, + UUID compositeSessionId, String tenantKey, Set sourceFamilies, UnifiedRuntimeEventBackend eventBackend, String driverKey, + Set driverKeys, + Boolean includeAllDrivers, + Set vehicleKeys, + Boolean includeAllVehicles, String driverSourceEntityId, String driverCardNation, String driverCardNumber, OffsetDateTime occurredFrom, OffsetDateTime occurredTo, Boolean expandVehicleEvents, - Integer vehicleExpansionPaddingMinutes + Integer vehicleExpansionPaddingMinutes, + Integer significantDrivingMinutes, + Integer minimumRestPeriodMinutes ) { public UnifiedRuntimeProcessingRequest toRuntimeRequest() { return new UnifiedRuntimeProcessingRequest( sessionId, + sessionIds, + compositeSessionId, tenantKey, sourceFamilies, eventBackend, driverKey, + driverKeys, + includeAllDrivers != null && includeAllDrivers, + vehicleKeys, + includeAllVehicles != null && includeAllVehicles, driverSourceEntityId, driverCardNation, driverCardNumber, diff --git a/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeTachographEsperScopeResultDto.java b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeTachographEsperScopeResultDto.java new file mode 100644 index 0000000..9fee16e --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeTachographEsperScopeResultDto.java @@ -0,0 +1,58 @@ +package at.procon.eventhub.processing.dto; + +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingPartitionResultDto; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto; +import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public record UnifiedRuntimeTachographEsperScopeResultDto( + UnifiedRuntimeProcessingRequest request, + int inputEventCount, + int selectedDriverCount, + int discoveredVehicleCount, + List discoveredVehicles, + Map driverResults, + List notes, + List warnings +) { + public UnifiedRuntimeTachographEsperScopeResultDto { + discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles); + driverResults = driverResults == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(driverResults)); + notes = notes == null ? List.of() : List.copyOf(notes); + warnings = warnings == null ? List.of() : List.copyOf(warnings); + } + + public static UnifiedRuntimeTachographEsperScopeResultDto fromGenericRuntimeEventProcessingResult( + RuntimeEventProcessingResultDto genericResult + ) { + if (genericResult == null) { + throw new IllegalArgumentException("genericResult must not be null"); + } + LinkedHashMap driverResults = new LinkedHashMap<>(); + for (Map.Entry entry : genericResult.partitionResults().entrySet()) { + Object value = entry.getValue().result(); + if (value instanceof UnifiedRuntimeDerivedProjectionResultDto projectionResult) { + driverResults.put(entry.getKey(), projectionResult); + } else { + throw new IllegalArgumentException("Cannot convert generic partition result for key " + + entry.getKey() + " to UnifiedRuntimeDerivedProjectionResultDto: " + + (value == null ? "null" : value.getClass().getName())); + } + } + return new UnifiedRuntimeTachographEsperScopeResultDto( + genericResult.request(), + genericResult.inputEventCount(), + genericResult.selectedPartitionCount(), + genericResult.discoveredVehicleCount(), + genericResult.discoveredVehicles(), + driverResults, + genericResult.notes(), + genericResult.warnings() + ); + } + +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingService.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingService.java new file mode 100644 index 0000000..9ca260b --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingService.java @@ -0,0 +1,26 @@ +package at.procon.eventhub.processing.eventprocessing; + +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 java.util.List; +import at.procon.eventhub.processing.eventprocessing.profile.RuntimeEventProcessingProfileRegistry; +import org.springframework.stereotype.Service; + +@Service +public class RuntimeEventProcessingService { + + private final RuntimeEventProcessingProfileRegistry profileRegistry; + + public RuntimeEventProcessingService(RuntimeEventProcessingProfileRegistry profileRegistry) { + this.profileRegistry = profileRegistry; + } + + public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) { + return profileRegistry.require(request.profileKey()).process(request); + } + + public List listProfiles() { + return profileRegistry.profileDescriptors(); + } +} 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 new file mode 100644 index 0000000..dceee3e --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventPartitioningApiRequest.java @@ -0,0 +1,46 @@ +package at.procon.eventhub.processing.eventprocessing.dto; + +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import java.util.Set; + +public record RuntimeEventPartitioningApiRequest( + RuntimeEventPartitioningStrategy strategy, + Set partitionKeys, + Boolean includeAllPartitions, + Set driverKeys, + Boolean includeAllDrivers, + Set vehicleKeys, + Boolean includeAllVehicles, + Boolean attachVehicleEvidence, + Integer vehicleEvidencePaddingMinutes +) { + public RuntimeEventPartitioningApiRequest { + strategy = strategy == null ? RuntimeEventPartitioningStrategy.CUSTOM_PROFILE : strategy; + partitionKeys = partitionKeys == null ? Set.of() : Set.copyOf(partitionKeys); + driverKeys = driverKeys == null ? Set.of() : Set.copyOf(driverKeys); + vehicleKeys = vehicleKeys == null ? Set.of() : Set.copyOf(vehicleKeys); + if (vehicleEvidencePaddingMinutes != null && vehicleEvidencePaddingMinutes < 0) { + throw new IllegalArgumentException("vehicleEvidencePaddingMinutes must not be negative"); + } + } + + public boolean allPartitions() { + return includeAllPartitions != null && includeAllPartitions; + } + + public boolean allDrivers() { + return includeAllDrivers != null && includeAllDrivers; + } + + public boolean allVehicles() { + return includeAllVehicles != null && includeAllVehicles; + } + + public boolean attachVehicleEvidenceOrDefault() { + return attachVehicleEvidence == null || attachVehicleEvidence; + } + + public int vehicleEvidencePaddingMinutesOrDefault(int fallback) { + return vehicleEvidencePaddingMinutes == null ? Math.max(0, fallback) : Math.max(0, vehicleEvidencePaddingMinutes); + } +} 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 new file mode 100644 index 0000000..31f31f6 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingApiRequest.java @@ -0,0 +1,48 @@ +package at.procon.eventhub.processing.eventprocessing.dto; + +import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public record RuntimeEventProcessingApiRequest( + String profileKey, + UnifiedRuntimeProcessingApiRequest scope, + RuntimeEventPartitioningApiRequest partitioning, + Map parameters +) { + public RuntimeEventProcessingApiRequest { + if (profileKey == null || profileKey.isBlank()) { + throw new IllegalArgumentException("profileKey must not be blank"); + } + profileKey = profileKey.trim(); + if (scope == null) { + throw new IllegalArgumentException("scope must not be null"); + } + partitioning = partitioning == null + ? new RuntimeEventPartitioningApiRequest(null, null, null, null, null, null, null, null, null) + : partitioning; + parameters = parameters == null + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(parameters)); + } + + public static RuntimeEventProcessingApiRequest tachographDriverEsper(UnifiedRuntimeProcessingApiRequest scope) { + return new RuntimeEventProcessingApiRequest( + "tachograph-driver-esper-v1", + scope, + new RuntimeEventPartitioningApiRequest( + at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy.DRIVER, + null, + scope != null ? scope.includeAllDrivers() : null, + scope != null ? scope.driverKeys() : null, + scope != null ? scope.includeAllDrivers() : null, + scope != null ? scope.vehicleKeys() : null, + scope != null ? scope.includeAllVehicles() : null, + null, + scope != null ? scope.vehicleExpansionPaddingMinutes() : null + ), + Map.of() + ); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingPartitionResultDto.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingPartitionResultDto.java new file mode 100644 index 0000000..982ba2b --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingPartitionResultDto.java @@ -0,0 +1,17 @@ +package at.procon.eventhub.processing.eventprocessing.dto; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public record RuntimeEventProcessingPartitionResultDto( + String partitionType, + String partitionKey, + String resultType, + Object result, + Map metadata +) { + public RuntimeEventProcessingPartitionResultDto { + metadata = metadata == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(metadata)); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingProfileDescriptorDto.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingProfileDescriptorDto.java new file mode 100644 index 0000000..5a4b5c4 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingProfileDescriptorDto.java @@ -0,0 +1,39 @@ +package at.procon.eventhub.processing.eventprocessing.dto; + +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import at.procon.eventhub.processing.eventprocessing.profile.RuntimeEventProcessingProfile; +import java.util.List; +import java.util.Set; + +public record RuntimeEventProcessingProfileDescriptorDto( + String profileKey, + String displayName, + String description, + RuntimeEventPartitioningStrategy defaultPartitioningStrategy, + List supportedPartitioningStrategies, + Set requiredParameters, + Set optionalParameters +) { + public RuntimeEventProcessingProfileDescriptorDto { + supportedPartitioningStrategies = supportedPartitioningStrategies == null + ? List.of() + : List.copyOf(supportedPartitioningStrategies); + requiredParameters = requiredParameters == null ? Set.of() : Set.copyOf(requiredParameters); + optionalParameters = optionalParameters == null ? Set.of() : Set.copyOf(optionalParameters); + } + + public static RuntimeEventProcessingProfileDescriptorDto from(RuntimeEventProcessingProfile profile) { + if (profile == null) { + throw new IllegalArgumentException("profile must not be null"); + } + return new RuntimeEventProcessingProfileDescriptorDto( + profile.profileKey(), + profile.displayName(), + profile.description(), + profile.defaultPartitioningStrategy(), + profile.supportedPartitioningStrategies(), + profile.requiredParameters(), + profile.optionalParameters() + ); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingResultDto.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingResultDto.java new file mode 100644 index 0000000..48568a2 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingResultDto.java @@ -0,0 +1,29 @@ +package at.procon.eventhub.processing.eventprocessing.dto; + +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public record RuntimeEventProcessingResultDto( + String profileKey, + RuntimeEventPartitioningStrategy partitioningStrategy, + UnifiedRuntimeProcessingRequest request, + int inputEventCount, + int selectedPartitionCount, + int discoveredVehicleCount, + List discoveredVehicles, + Map partitionResults, + List notes, + List warnings +) { + public RuntimeEventProcessingResultDto { + discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles); + partitionResults = partitionResults == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(partitionResults)); + 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/partition/RuntimeEventPartitioningStrategy.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventPartitioningStrategy.java new file mode 100644 index 0000000..571380a --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventPartitioningStrategy.java @@ -0,0 +1,10 @@ +package at.procon.eventhub.processing.eventprocessing.partition; + +public enum RuntimeEventPartitioningStrategy { + NONE, + DRIVER, + VEHICLE, + DRIVER_VEHICLE, + SOURCE_FAMILY, + CUSTOM_PROFILE +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventScopeClassifier.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventScopeClassifier.java new file mode 100644 index 0000000..564e90a --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventScopeClassifier.java @@ -0,0 +1,73 @@ +package at.procon.eventhub.processing.eventprocessing.partition; + +import at.procon.eventhub.dto.DriverRefDto; +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.dto.VehicleRefDto; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.stereotype.Component; + +@Component +public class RuntimeEventScopeClassifier { + + public RuntimeEventScopeType classify(EventHubEventDto event) { + if (event == null) { + return RuntimeEventScopeType.UNKNOWN; + } + boolean hasDriver = hasDriver(event); + boolean hasVehicle = hasVehicle(event); + if (hasDriver && hasVehicle) { + return RuntimeEventScopeType.DRIVER_VEHICLE_SCOPED; + } + if (hasDriver) { + return RuntimeEventScopeType.DRIVER_SCOPED; + } + if (hasVehicle) { + return RuntimeEventScopeType.VEHICLE_SCOPED; + } + return RuntimeEventScopeType.GLOBAL_SUPPORT; + } + + public boolean hasDriver(EventHubEventDto event) { + if (event == null) { + return false; + } + if (text(rawPayload(event), "driverKey") != null) { + return true; + } + DriverRefDto driverRef = event.driverRef(); + return driverRef != null && driverRef.hasAnyReference(); + } + + public boolean hasVehicle(EventHubEventDto event) { + if (event == null) { + return false; + } + JsonNode raw = rawPayload(event); + if (text(raw, "vehicleKey") != null || text(raw, "registrationKey") != null) { + return true; + } + VehicleRefDto vehicleRef = event.vehicleRef(); + return vehicleRef != null && vehicleRef.hasAnyReference(); + } + + private JsonNode rawPayload(EventHubEventDto event) { + JsonNode payload = event.payload(); + if (payload == null || payload.isNull()) { + return null; + } + JsonNode raw = payload.get("raw"); + return raw == null || raw.isNull() ? payload : raw; + } + + private String text(JsonNode node, String field) { + if (node == null || field == null) { + return null; + } + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + String text = value.asText(null); + return text == null || text.isBlank() ? null : text.trim(); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventScopeType.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventScopeType.java new file mode 100644 index 0000000..5e1e90e --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/partition/RuntimeEventScopeType.java @@ -0,0 +1,9 @@ +package at.procon.eventhub.processing.eventprocessing.partition; + +public enum RuntimeEventScopeType { + DRIVER_SCOPED, + VEHICLE_SCOPED, + DRIVER_VEHICLE_SCOPED, + GLOBAL_SUPPORT, + UNKNOWN +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfile.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfile.java new file mode 100644 index 0000000..b7f04cb --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfile.java @@ -0,0 +1,37 @@ +package at.procon.eventhub.processing.eventprocessing.profile; + +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto; +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import java.util.List; +import java.util.Set; + +public interface RuntimeEventProcessingProfile { + + String profileKey(); + + RuntimeEventPartitioningStrategy defaultPartitioningStrategy(); + + default String displayName() { + return profileKey(); + } + + default String description() { + return ""; + } + + default List supportedPartitioningStrategies() { + RuntimeEventPartitioningStrategy defaultStrategy = defaultPartitioningStrategy(); + return defaultStrategy == null ? List.of() : List.of(defaultStrategy); + } + + default Set requiredParameters() { + return Set.of(); + } + + default Set optionalParameters() { + return Set.of(); + } + + RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request); +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfileRegistry.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfileRegistry.java new file mode 100644 index 0000000..f93c6e1 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfileRegistry.java @@ -0,0 +1,49 @@ +package at.procon.eventhub.processing.eventprocessing.profile; + +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingProfileDescriptorDto; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +public class RuntimeEventProcessingProfileRegistry { + + private final Map profilesByKey; + + public RuntimeEventProcessingProfileRegistry(List profiles) { + LinkedHashMap byKey = new LinkedHashMap<>(); + for (RuntimeEventProcessingProfile profile : profiles == null ? List.of() : profiles) { + String key = profile.profileKey(); + if (key == null || key.isBlank()) { + throw new IllegalStateException("Runtime event processing profile returned a blank profileKey: " + profile.getClass().getName()); + } + RuntimeEventProcessingProfile previous = byKey.putIfAbsent(key, profile); + if (previous != null) { + throw new IllegalStateException("Duplicate runtime event processing profileKey: " + key); + } + } + this.profilesByKey = Collections.unmodifiableMap(byKey); + } + + public RuntimeEventProcessingProfile require(String profileKey) { + String normalized = profileKey == null ? null : profileKey.trim(); + RuntimeEventProcessingProfile profile = normalized == null ? null : profilesByKey.get(normalized); + if (profile == null) { + throw new IllegalArgumentException("Unknown runtime event processing profileKey: " + profileKey + + ". Available profiles: " + profilesByKey.keySet()); + } + return profile; + } + + public Map profilesByKey() { + return profilesByKey; + } + + public List profileDescriptors() { + return profilesByKey.values().stream() + .map(RuntimeEventProcessingProfileDescriptorDto::from) + .toList(); + } +} 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 new file mode 100644 index 0000000..99de4fe --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfile.java @@ -0,0 +1,225 @@ +package at.procon.eventhub.processing.eventprocessing.profile; + +import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.dto.UnifiedRuntimeTachographEsperScopeResultDto; +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.service.UnifiedRuntimeTachographEsperScopeProcessingService; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +public class TachographDriverEsperRuntimeEventProcessingProfile implements RuntimeEventProcessingProfile { + + public static final String PROFILE_KEY = "tachograph-driver-esper-v1"; + + private final UnifiedRuntimeTachographEsperScopeProcessingService tachographScopeProcessingService; + + public TachographDriverEsperRuntimeEventProcessingProfile( + UnifiedRuntimeTachographEsperScopeProcessingService tachographScopeProcessingService + ) { + this.tachographScopeProcessingService = tachographScopeProcessingService; + } + + @Override + public String profileKey() { + return PROFILE_KEY; + } + + @Override + public RuntimeEventPartitioningStrategy defaultPartitioningStrategy() { + return RuntimeEventPartitioningStrategy.DRIVER; + } + + @Override + public String displayName() { + return "Tachograph Driver Esper Processing"; + } + + @Override + public String description() { + return "Runs the shared tachograph driver Esper processing pipeline over Runtime Processing event scopes. " + + "The profile partitions mixed runtime events by driver before invoking the event-input EPL pipeline."; + } + + @Override + public List supportedPartitioningStrategies() { + return List.of(RuntimeEventPartitioningStrategy.DRIVER); + } + + @Override + public Set optionalParameters() { + return Set.of("significantDrivingMinutes", "minimumRestPeriodMinutes", "attachVehicleOnlyEvents", "vehicleEvidencePaddingMinutes"); + } + + @Override + public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) { + UnifiedRuntimeProcessingApiRequest tachographScopeRequest = applyGenericRequest(request.scope(), request.partitioning(), request.parameters()); + UnifiedRuntimeTachographEsperScopeResultDto tachographResult = tachographScopeProcessingService.processScope(tachographScopeRequest); + + 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() + ) + ) + )); + + return new RuntimeEventProcessingResultDto( + profileKey(), + RuntimeEventPartitioningStrategy.DRIVER, + tachographResult.request(), + tachographResult.inputEventCount(), + tachographResult.selectedDriverCount(), + tachographResult.discoveredVehicleCount(), + tachographResult.discoveredVehicles(), + partitionResults, + tachographResult.notes(), + tachographResult.warnings() + ); + } + + private UnifiedRuntimeProcessingApiRequest applyGenericRequest( + UnifiedRuntimeProcessingApiRequest scope, + RuntimeEventPartitioningApiRequest partitioning, + Map parameters + ) { + if (scope == null) { + throw new IllegalArgumentException("scope must not be null for profile " + PROFILE_KEY + "."); + } + boolean includeAllDrivers = scope.includeAllDrivers() != null && scope.includeAllDrivers(); + java.util.Set driverKeys = scope.driverKeys(); + boolean includeAllVehicles = scope.includeAllVehicles() != null && scope.includeAllVehicles(); + java.util.Set vehicleKeys = scope.vehicleKeys(); + + if (partitioning != null) { + RuntimeEventPartitioningStrategy strategy = partitioning.strategy() == null + ? RuntimeEventPartitioningStrategy.DRIVER + : partitioning.strategy(); + if (strategy != RuntimeEventPartitioningStrategy.DRIVER + && strategy != RuntimeEventPartitioningStrategy.CUSTOM_PROFILE) { + throw new IllegalArgumentException("Profile " + PROFILE_KEY + + " currently supports DRIVER partitioning only. Requested: " + strategy); + } + includeAllDrivers = includeAllDrivers || partitioning.allPartitions() || partitioning.allDrivers(); + if (!partitioning.driverKeys().isEmpty()) { + driverKeys = partitioning.driverKeys(); + } else if (!partitioning.partitionKeys().isEmpty()) { + driverKeys = partitioning.partitionKeys(); + } + includeAllVehicles = includeAllVehicles || partitioning.allVehicles(); + if (!partitioning.vehicleKeys().isEmpty()) { + vehicleKeys = partitioning.vehicleKeys(); + } + } + + Integer significantDrivingMinutes = integerParameter(parameters, "significantDrivingMinutes", scope.significantDrivingMinutes()); + Integer minimumRestPeriodMinutes = integerParameter(parameters, "minimumRestPeriodMinutes", scope.minimumRestPeriodMinutes()); + boolean attachVehicleOnlyEvents = booleanParameter(parameters, "attachVehicleOnlyEvents", + partitioning == null ? scope.expandVehicleEvents() == null || scope.expandVehicleEvents() : partitioning.attachVehicleEvidenceOrDefault()); + Integer vehicleEvidencePaddingMinutes = nonNegativeIntegerParameter(parameters, "vehicleEvidencePaddingMinutes", + partitioning == null + ? scope.vehicleExpansionPaddingMinutes() + : partitioning.vehicleEvidencePaddingMinutesOrDefault(scope.vehicleExpansionPaddingMinutes() == null ? 0 : scope.vehicleExpansionPaddingMinutes())); + + return new UnifiedRuntimeProcessingApiRequest( + scope.sessionId(), + scope.sessionIds(), + scope.compositeSessionId(), + scope.tenantKey(), + scope.sourceFamilies(), + scope.eventBackend(), + scope.driverKey(), + driverKeys, + includeAllDrivers, + vehicleKeys, + includeAllVehicles, + scope.driverSourceEntityId(), + scope.driverCardNation(), + scope.driverCardNumber(), + scope.occurredFrom(), + scope.occurredTo(), + attachVehicleOnlyEvents, + vehicleEvidencePaddingMinutes, + significantDrivingMinutes, + minimumRestPeriodMinutes + ); + } + + private boolean booleanParameter(Map parameters, String key, boolean fallback) { + if (parameters == null || !parameters.containsKey(key)) { + return fallback; + } + Object value = parameters.get(key); + if (value == null) { + return fallback; + } + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + String text = value.toString(); + if (text == null || text.isBlank()) { + return fallback; + } + return Boolean.parseBoolean(text.trim()); + } + + private Integer nonNegativeIntegerParameter(Map parameters, String key, Integer fallback) { + if (parameters == null || !parameters.containsKey(key)) { + return fallback; + } + Object value = parameters.get(key); + if (value == null) { + return fallback; + } + if (value instanceof Number number) { + return Math.max(0, number.intValue()); + } + String text = value.toString(); + if (text == null || text.isBlank()) { + return fallback; + } + try { + return Math.max(0, Integer.parseInt(text.trim())); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Parameter '" + key + "' must be an integer.", ex); + } + } + + private Integer integerParameter(Map parameters, String key, Integer fallback) { + if (parameters == null || !parameters.containsKey(key)) { + return fallback; + } + Object value = parameters.get(key); + if (value == null) { + return fallback; + } + if (value instanceof Number number) { + return Math.max(1, number.intValue()); + } + String text = value.toString(); + if (text == null || text.isBlank()) { + return fallback; + } + try { + return Math.max(1, Integer.parseInt(text.trim())); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Parameter '" + key + "' must be an integer.", ex); + } + } +} diff --git a/src/main/java/at/procon/eventhub/processing/model/RuntimeDriverVehicleEvidenceAttachmentResult.java b/src/main/java/at/procon/eventhub/processing/model/RuntimeDriverVehicleEvidenceAttachmentResult.java new file mode 100644 index 0000000..7dec607 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/model/RuntimeDriverVehicleEvidenceAttachmentResult.java @@ -0,0 +1,24 @@ +package at.procon.eventhub.processing.model; + +import at.procon.eventhub.dto.EventHubEventDto; +import java.util.List; + +public record RuntimeDriverVehicleEvidenceAttachmentResult( + String driverKey, + List directDriverEvents, + List attachedVehicleEvidenceEvents, + List mergedEvents, + int vehicleUsageIntervalCount, + int candidateVehicleEvidenceEventCount, + int ignoredVehicleEvidenceEventCount, + List notes, + List warnings +) { + public 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); + 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/model/UnifiedDriverEventsRequest.java b/src/main/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequest.java index 12c7e51..1dc875e 100644 --- a/src/main/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequest.java +++ b/src/main/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequest.java @@ -36,9 +36,6 @@ public record UnifiedDriverEventsRequest( } if (sourceFamily == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION) { Objects.requireNonNull(sessionId, "sessionId must not be null"); - if (driverKey == null) { - throw new IllegalArgumentException("driverKey must not be blank"); - } } else { if (tenantKey == null) { throw new IllegalArgumentException("tenantKey must not be blank"); @@ -50,6 +47,10 @@ public record UnifiedDriverEventsRequest( } } + /** + * File-session requests may omit driverKey when the caller intentionally wants + * to load all drivers from the session and partition them later in runtime scope processing. + */ public static UnifiedDriverEventsRequest forTachographFileSession( UUID sessionId, String driverKey, diff --git a/src/main/java/at/procon/eventhub/processing/model/UnifiedRuntimeProcessingRequest.java b/src/main/java/at/procon/eventhub/processing/model/UnifiedRuntimeProcessingRequest.java index 8ae9e0a..ff72a6c 100644 --- a/src/main/java/at/procon/eventhub/processing/model/UnifiedRuntimeProcessingRequest.java +++ b/src/main/java/at/procon/eventhub/processing/model/UnifiedRuntimeProcessingRequest.java @@ -2,15 +2,24 @@ package at.procon.eventhub.processing.model; import at.procon.eventhub.reference.DriverCardNumberNormalizer; import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; import java.util.UUID; public record UnifiedRuntimeProcessingRequest( UUID sessionId, + List sessionIds, + UUID compositeSessionId, String tenantKey, Set sourceFamilies, UnifiedRuntimeEventBackend eventBackend, String driverKey, + Set driverKeys, + boolean includeAllDrivers, + Set vehicleKeys, + boolean includeAllVehicles, String driverSourceEntityId, String driverCardNation, String driverCardNumber, @@ -20,32 +29,64 @@ public record UnifiedRuntimeProcessingRequest( int vehicleExpansionPaddingMinutes ) { public UnifiedRuntimeProcessingRequest { - driverKey = normalize(driverKey); - tenantKey = normalize(tenantKey); - driverSourceEntityId = normalize(driverSourceEntityId); - driverCardNation = normalizeUpper(driverCardNation); - driverCardNumber = normalizeDriverCardNumber(driverCardNumber); - boolean includesFileSession = sourceFamilies != null && sourceFamilies.contains(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION); - boolean includesExternalDb = sourceFamilies != null && sourceFamilies.stream() - .anyMatch(family -> family == UnifiedEventSourceFamily.TACHOGRAPH_DB || family == UnifiedEventSourceFamily.YELLOWFOX_DB); - if (tenantKey == null) { - if (!includesFileSession || includesExternalDb) { - throw new IllegalArgumentException("tenantKey must not be blank"); - } - } if (sourceFamilies == null || sourceFamilies.isEmpty()) { throw new IllegalArgumentException("sourceFamilies must not be empty"); } sourceFamilies = Set.copyOf(sourceFamilies); eventBackend = eventBackend == null ? UnifiedRuntimeEventBackend.SOURCE_DB : eventBackend; - if (includesFileSession && sessionId == null) { - throw new IllegalArgumentException("sessionId must not be null when TACHOGRAPH_FILE_SESSION is selected."); + sessionIds = normalizeSessionIds(sessionId, sessionIds); + if (sessionId == null && !sessionIds.isEmpty()) { + sessionId = sessionIds.get(0); } - if (includesFileSession && driverKey == null) { - throw new IllegalArgumentException("driverKey must not be blank when TACHOGRAPH_FILE_SESSION is selected."); + if (compositeSessionId != null && !sessionIds.isEmpty()) { + throw new IllegalArgumentException("Use either compositeSessionId or sessionId/sessionIds, not both."); } - if (includesExternalDb && driverSourceEntityId == null && driverCardNumber == null) { - throw new IllegalArgumentException("At least one driver selector must be provided."); + driverKey = normalize(driverKey); + driverKeys = normalizeStrings(driverKeys); + if (driverKey != null) { + LinkedHashSet mergedDriverKeys = new LinkedHashSet<>(driverKeys); + mergedDriverKeys.add(driverKey); + driverKeys = Set.copyOf(mergedDriverKeys); + } + if (driverKey == null && driverKeys.size() == 1) { + driverKey = driverKeys.iterator().next(); + } + vehicleKeys = normalizeStrings(vehicleKeys); + tenantKey = normalize(tenantKey); + driverSourceEntityId = normalize(driverSourceEntityId); + driverCardNation = normalizeUpper(driverCardNation); + driverCardNumber = normalizeDriverCardNumber(driverCardNumber); + boolean includesFileSession = sourceFamilies.contains(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION); + boolean includesExternalDb = sourceFamilies.stream() + .anyMatch(family -> family == UnifiedEventSourceFamily.TACHOGRAPH_DB || family == UnifiedEventSourceFamily.YELLOWFOX_DB); + if (includesFileSession && eventBackend == UnifiedRuntimeEventBackend.EVENTHUB_DB) { + throw new IllegalArgumentException("TACHOGRAPH_FILE_SESSION runtime processing currently supports SOURCE_DB backend only."); + } + if (tenantKey == null) { + if (!includesFileSession || includesExternalDb) { + throw new IllegalArgumentException("tenantKey must not be blank"); + } + } + if (includesFileSession && compositeSessionId == null && sessionIds.isEmpty()) { + throw new IllegalArgumentException("sessionId, sessionIds or compositeSessionId must be provided when TACHOGRAPH_FILE_SESSION is selected."); + } + if (includesFileSession && driverKey == null && driverKeys.isEmpty() && !includeAllDrivers) { + throw new IllegalArgumentException("driverKey, driverKeys or includeAllDrivers must be provided when TACHOGRAPH_FILE_SESSION is selected."); + } + if (includesExternalDb && driverSourceEntityId == null && driverCardNumber == null + && driverKeys.isEmpty() && !includeAllDrivers) { + throw new IllegalArgumentException("At least one driver selector, driverKeys or includeAllDrivers must be provided."); + } + if (includesExternalDb && (includeAllDrivers || !driverKeys.isEmpty()) + && (occurredFrom == null || occurredTo == null)) { + throw new IllegalArgumentException("occurredFrom and occurredTo are required when loading broad external DB runtime scopes."); + } + if (eventBackend == UnifiedRuntimeEventBackend.EVENTHUB_DB + && includesExternalDb + && driverSourceEntityId == null + && driverCardNumber == null + && (includeAllDrivers || !driverKeys.isEmpty())) { + throw new IllegalArgumentException("Broad multi-driver EVENTHUB_DB runtime scopes are not supported yet; provide a concrete EventHub driver selector or use SOURCE_DB."); } if (occurredFrom != null && occurredTo != null && occurredTo.isBefore(occurredFrom)) { throw new IllegalArgumentException("occurredTo must not be before occurredFrom"); @@ -116,11 +157,17 @@ public record UnifiedRuntimeProcessingRequest( int vehicleExpansionPaddingMinutes ) { return new UnifiedRuntimeProcessingRequest( + null, + List.of(), null, tenantKey, sourceFamilies, eventBackend, null, + Set.of(), + false, + Set.of(), + false, driverSourceEntityId, driverCardNation, driverCardNumber, @@ -143,11 +190,17 @@ public record UnifiedRuntimeProcessingRequest( int vehicleExpansionPaddingMinutes ) { return new UnifiedRuntimeProcessingRequest( + null, + List.of(), null, tenantKey, sourceFamilies, UnifiedRuntimeEventBackend.EVENTHUB_DB, null, + Set.of(), + false, + Set.of(), + false, driverSourceEntityId, driverCardNation, driverCardNumber, @@ -168,10 +221,76 @@ public record UnifiedRuntimeProcessingRequest( ) { return new UnifiedRuntimeProcessingRequest( sessionId, + List.of(), + null, null, Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), UnifiedRuntimeEventBackend.SOURCE_DB, driverKey, + Set.of(), + false, + Set.of(), + false, + null, + null, + null, + occurredFrom, + occurredTo, + expandVehicleEvents, + vehicleExpansionPaddingMinutes + ); + } + + public static UnifiedRuntimeProcessingRequest forTachographFileSessions( + List sessionIds, + String driverKey, + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo, + boolean expandVehicleEvents, + int vehicleExpansionPaddingMinutes + ) { + return new UnifiedRuntimeProcessingRequest( + null, + sessionIds, + null, + null, + Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), + UnifiedRuntimeEventBackend.SOURCE_DB, + driverKey, + Set.of(), + false, + Set.of(), + false, + null, + null, + null, + occurredFrom, + occurredTo, + expandVehicleEvents, + vehicleExpansionPaddingMinutes + ); + } + + public static UnifiedRuntimeProcessingRequest forTachographCompositeSession( + UUID compositeSessionId, + String driverKey, + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo, + boolean expandVehicleEvents, + int vehicleExpansionPaddingMinutes + ) { + return new UnifiedRuntimeProcessingRequest( + null, + List.of(), + compositeSessionId, + null, + Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), + UnifiedRuntimeEventBackend.SOURCE_DB, + driverKey, + Set.of(), + false, + Set.of(), + false, null, null, null, @@ -190,6 +309,58 @@ public record UnifiedRuntimeProcessingRequest( return occurredTo == null ? null : occurredTo.plusMinutes(vehicleExpansionPaddingMinutes); } + private static List normalizeSessionIds(UUID sessionId, List sessionIds) { + LinkedHashSet result = new LinkedHashSet<>(); + if (sessionId != null) { + result.add(sessionId); + } + if (sessionIds != null) { + result.addAll(sessionIds.stream().filter(value -> value != null).toList()); + } + return List.copyOf(new ArrayList<>(result)); + } + + public UnifiedRuntimeProcessingRequest withDriverKey(String value) { + return new UnifiedRuntimeProcessingRequest( + sessionId, + sessionIds, + compositeSessionId, + tenantKey, + sourceFamilies, + eventBackend, + value, + Set.of(), + false, + vehicleKeys, + includeAllVehicles, + driverSourceEntityId, + driverCardNation, + driverCardNumber, + occurredFrom, + occurredTo, + expandVehicleEvents, + vehicleExpansionPaddingMinutes + ); + } + + public boolean scopeDriverSelectionRequested() { + return includeAllDrivers || driverKeys.size() > 1 || (driverKey == null && !driverKeys.isEmpty()); + } + + private static Set normalizeStrings(Set values) { + if (values == null || values.isEmpty()) { + return Set.of(); + } + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String value : values) { + String normalizedValue = normalize(value); + if (normalizedValue != null) { + normalized.add(normalizedValue); + } + } + return Set.copyOf(normalized); + } + private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); } diff --git a/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java b/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java new file mode 100644 index 0000000..26873ed --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java @@ -0,0 +1,302 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.dto.VehicleRefDto; +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeClassifier; +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeType; +import at.procon.eventhub.processing.model.RuntimeDriverVehicleEvidenceAttachmentResult; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import org.springframework.stereotype.Service; + +@Service +public class RuntimeDriverVehicleEvidenceAttachmentService { + + private final UnifiedEventTimelineReconstructor timelineReconstructor; + private final RuntimeEventScopeClassifier scopeClassifier; + + public RuntimeDriverVehicleEvidenceAttachmentService( + UnifiedEventTimelineReconstructor timelineReconstructor, + RuntimeEventScopeClassifier scopeClassifier + ) { + this.timelineReconstructor = timelineReconstructor; + this.scopeClassifier = scopeClassifier; + } + + public RuntimeDriverVehicleEvidenceAttachmentResult attachVehicleEvidence( + String driverKey, + List directDriverEvents, + List runtimeScopeEvents, + boolean attachVehicleOnlyEvents, + int vehicleEvidencePaddingMinutes + ) { + List safeDriverEvents = directDriverEvents == null ? List.of() : List.copyOf(directDriverEvents); + List safeScopeEvents = runtimeScopeEvents == null ? List.of() : List.copyOf(runtimeScopeEvents); + int paddingMinutes = Math.max(0, vehicleEvidencePaddingMinutes); + + List notes = new ArrayList<>(); + List warnings = new ArrayList<>(); + if (!attachVehicleOnlyEvents) { + notes.add("Vehicle-only evidence attachment is disabled for driver partition " + driverKey + "."); + return new RuntimeDriverVehicleEvidenceAttachmentResult( + driverKey, + safeDriverEvents, + List.of(), + deduplicateAndSort(safeDriverEvents, List.of()), + 0, + 0, + 0, + notes, + warnings + ); + } + + ResolvedDriverTimeline timeline = timelineReconstructor.reconstruct(null, driverKey, safeDriverEvents); + List usageIntervals = mergeVehicleUsageIntervals(timeline.vehicleUsageIntervals()); + List candidateVehicleEvidence = safeScopeEvents.stream() + .filter(event -> scopeClassifier.classify(event) == RuntimeEventScopeType.VEHICLE_SCOPED) + .toList(); + List attached = new ArrayList<>(); + int ignored = 0; + for (EventHubEventDto vehicleEvent : candidateVehicleEvidence) { + List matchingIntervals = matchingUsageIntervals(vehicleEvent, usageIntervals, paddingMinutes); + if (matchingIntervals.isEmpty()) { + ignored++; + continue; + } + attached.add(vehicleEvent); + if (matchingIntervals.size() > 1) { + warnings.add("Vehicle-only event " + vehicleEvent.externalSourceEventId() + + " matched multiple vehicle-usage intervals for driver " + driverKey + + "; it was attached once after deduplication."); + } + } + + notes.add("Vehicle-only evidence attachment used " + usageIntervals.size() + + " reconstructed vehicle-usage interval(s) for driver " + driverKey + "."); + notes.add("Vehicle-only evidence padding minutes: " + paddingMinutes + "."); + notes.add("Candidate vehicle-only evidence events: " + candidateVehicleEvidence.size() + "."); + notes.add("Attached vehicle-only evidence events: " + attached.size() + "."); + notes.add("Ignored vehicle-only evidence events: " + ignored + "."); + if (usageIntervals.isEmpty() && !candidateVehicleEvidence.isEmpty()) { + warnings.add("Vehicle-only evidence was available for driver " + driverKey + + ", but no driver vehicle-usage intervals were reconstructed; no vehicle-only evidence was attached."); + } + + return new RuntimeDriverVehicleEvidenceAttachmentResult( + driverKey, + safeDriverEvents, + attached, + deduplicateAndSort(safeDriverEvents, attached), + usageIntervals.size(), + candidateVehicleEvidence.size(), + ignored, + notes, + warnings + ); + } + + private List matchingUsageIntervals( + EventHubEventDto vehicleEvent, + List usageIntervals, + int paddingMinutes + ) { + if (vehicleEvent == null || vehicleEvent.occurredAt() == null || usageIntervals == null || usageIntervals.isEmpty()) { + return List.of(); + } + List result = new ArrayList<>(); + for (ResolvedVehicleUsageInterval interval : usageIntervals) { + if (matchesVehicle(vehicleEvent, interval) && timeInside(vehicleEvent.occurredAt(), interval, paddingMinutes)) { + result.add(interval); + } + } + return List.copyOf(result); + } + + private boolean timeInside(OffsetDateTime occurredAt, ResolvedVehicleUsageInterval interval, int paddingMinutes) { + if (occurredAt == null || interval == null || interval.from() == null) { + return false; + } + OffsetDateTime from = interval.from().minusMinutes(paddingMinutes); + OffsetDateTime to = interval.to() == null ? OffsetDateTime.MAX : interval.to().plusMinutes(paddingMinutes); + return !occurredAt.isBefore(from) && !occurredAt.isAfter(to); + } + + private boolean matchesVehicle(EventHubEventDto event, ResolvedVehicleUsageInterval interval) { + if (event == null || interval == null) { + return false; + } + Set eventKeys = vehicleKeys(event); + Set intervalKeys = vehicleKeys(interval); + if (eventKeys.isEmpty() || intervalKeys.isEmpty()) { + return false; + } + return eventKeys.stream().anyMatch(intervalKeys::contains); + } + + private Set vehicleKeys(EventHubEventDto event) { + LinkedHashSet result = new LinkedHashSet<>(); + JsonNode raw = rawPayload(event); + add(result, text(raw, "vehicleKey")); + add(result, text(raw, "registrationKey")); + VehicleRefDto vehicleRef = event.vehicleRef(); + if (vehicleRef != null) { + add(result, vehicleRef.sourceVehicleEntityId()); + add(result, vehicleRef.sourceRegistrationEntityId()); + add(result, vehicleRef.vin()); + if (vehicleRef.vin() != null) { + add(result, "VIN:" + vehicleRef.vin()); + } + if (vehicleRef.vehicleRegistration() != null) { + String registrationKey = vehicleRef.vehicleRegistration().stableKey(); + add(result, registrationKey); + add(result, "VR:" + registrationKey); + } + } + return Set.copyOf(result); + } + + private Set vehicleKeys(ResolvedVehicleUsageInterval interval) { + LinkedHashSet result = new LinkedHashSet<>(); + add(result, interval.vehicleKey()); + add(result, interval.registrationKey()); + if (interval.vehicleKey() != null) { + add(result, "VIN:" + interval.vehicleKey()); + } + if (interval.registrationKey() != null) { + add(result, "VR:" + interval.registrationKey()); + } + return Set.copyOf(result); + } + + private void add(Set keys, String value) { + if (value != null && !value.isBlank()) { + keys.add(value.trim()); + } + } + + private List mergeVehicleUsageIntervals(List intervals) { + if (intervals == null || intervals.isEmpty()) { + return List.of(); + } + List sorted = intervals.stream() + .sorted(Comparator.comparing(ResolvedVehicleUsageInterval::from, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(ResolvedVehicleUsageInterval::to, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(ResolvedVehicleUsageInterval::intervalId, Comparator.nullsLast(String::compareTo))) + .toList(); + List merged = new ArrayList<>(); + for (ResolvedVehicleUsageInterval next : sorted) { + if (merged.isEmpty()) { + merged.add(next); + continue; + } + ResolvedVehicleUsageInterval current = merged.get(merged.size() - 1); + if (canMerge(current, next)) { + merged.set(merged.size() - 1, merge(current, next)); + } else { + merged.add(next); + } + } + return List.copyOf(merged); + } + + private boolean canMerge(ResolvedVehicleUsageInterval left, ResolvedVehicleUsageInterval right) { + if (left == null || right == null || left.to() == null || right.from() == null) { + return false; + } + return Objects.equals(left.driverKey(), right.driverKey()) + && Objects.equals(left.registrationKey(), right.registrationKey()) + && Objects.equals(left.vehicleKey(), right.vehicleKey()) + && !right.from().isAfter(left.to().plusSeconds(1)); + } + + private ResolvedVehicleUsageInterval merge(ResolvedVehicleUsageInterval left, ResolvedVehicleUsageInterval right) { + LinkedHashSet sourceIntervalIds = new LinkedHashSet<>(); + if (left.sourceIntervalIds() != null) { + sourceIntervalIds.addAll(left.sourceIntervalIds()); + } + if (right.sourceIntervalIds() != null) { + sourceIntervalIds.addAll(right.sourceIntervalIds()); + } + OffsetDateTime end = left.to(); + if (right.to() != null && (end == null || right.to().isAfter(end))) { + end = right.to(); + } + return ResolvedVehicleUsageInterval.resolved( + left.sessionId(), + left.driverKey(), + left.intervalId(), + left.from(), + end, + left.odometerBeginKm(), + right.odometerEndKm() == null ? left.odometerEndKm() : right.odometerEndKm(), + left.registrationKey(), + left.vehicleKey(), + left.sourceKind(), + List.copyOf(sourceIntervalIds) + ); + } + + private List deduplicateAndSort( + List directDriverEvents, + List vehicleEvidenceEvents + ) { + LinkedHashMap byKey = new LinkedHashMap<>(); + appendDeduplicated(byKey, directDriverEvents); + appendDeduplicated(byKey, vehicleEvidenceEvents); + return sort(new ArrayList<>(byKey.values())); + } + + private void appendDeduplicated(LinkedHashMap byKey, List events) { + for (EventHubEventDto event : events == null ? List.of() : events) { + byKey.putIfAbsent(dedupKey(event), event); + } + } + + private String dedupKey(EventHubEventDto event) { + String sourceKey = event.packageInfo() != null && event.packageInfo().eventSource() != null + ? event.packageInfo().eventSource().stableKey() + : "NO_SOURCE"; + return sourceKey + "|" + event.externalSourceEventId(); + } + + private List sort(List events) { + return (events == null ? List.of() : events).stream() + .sorted(Comparator.comparing(EventHubEventDto::occurredAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(event -> event.eventDomain() == null ? "" : event.eventDomain().name()) + .thenComparing(event -> event.eventType() == null ? "" : event.eventType().name()) + .thenComparing(event -> event.lifecycle() == null ? "" : event.lifecycle().name()) + .thenComparing(EventHubEventDto::externalSourceEventId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private JsonNode rawPayload(EventHubEventDto event) { + JsonNode payload = event.payload(); + if (payload == null || payload.isNull()) { + return null; + } + JsonNode raw = payload.get("raw"); + return raw == null || raw.isNull() ? payload : raw; + } + + private String text(JsonNode node, String field) { + if (node == null || field == null) { + return null; + } + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + String text = value.asText(null); + return text == null || text.isBlank() ? null : text.trim(); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionRuntimeEventLoader.java b/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionRuntimeEventLoader.java index a8bd91a..157946e 100644 --- a/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionRuntimeEventLoader.java +++ b/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionRuntimeEventLoader.java @@ -7,7 +7,14 @@ import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend; import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; import at.procon.eventhub.processing.model.UnifiedVehicleEventsRequest; +import at.procon.eventhub.service.EventAcquisitionRecordKeyService; +import at.procon.eventhub.service.EventHubEventSorter; +import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionNotFoundException; +import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionRepository; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.UUID; import org.springframework.stereotype.Component; @Component @@ -15,13 +22,22 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve private final UnifiedDriverEventSourceService driverEventSourceService; private final UnifiedVehicleEventSourceService vehicleEventSourceService; + private final TachographCompositeSessionRepository compositeSessionRepository; + private final EventAcquisitionRecordKeyService eventKeyService; + private final EventHubEventSorter eventSorter; public TachographFileSessionRuntimeEventLoader( UnifiedDriverEventSourceService driverEventSourceService, - UnifiedVehicleEventSourceService vehicleEventSourceService + UnifiedVehicleEventSourceService vehicleEventSourceService, + TachographCompositeSessionRepository compositeSessionRepository, + EventAcquisitionRecordKeyService eventKeyService, + EventHubEventSorter eventSorter ) { this.driverEventSourceService = driverEventSourceService; this.vehicleEventSourceService = vehicleEventSourceService; + this.compositeSessionRepository = compositeSessionRepository; + this.eventKeyService = eventKeyService; + this.eventSorter = eventSorter; } @Override @@ -32,14 +48,18 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve @Override public List loadDriverEvents(UnifiedRuntimeProcessingRequest request) { - return driverEventSourceService.loadDriverEvents( - UnifiedDriverEventsRequest.forTachographFileSession( - request.sessionId(), - request.driverKey(), - request.occurredFrom(), - request.occurredTo() - ) - ); + List result = new ArrayList<>(); + for (UUID sessionId : resolveSessionIds(request)) { + result.addAll(driverEventSourceService.loadDriverEvents( + UnifiedDriverEventsRequest.forTachographFileSession( + sessionId, + request.driverKey(), + request.occurredFrom(), + request.occurredTo() + ) + )); + } + return deduplicateBySignatureAndSort(result); } @Override @@ -47,16 +67,37 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve UnifiedRuntimeProcessingRequest request, UnifiedDiscoveredVehicleRef vehicleRef ) { - return vehicleEventSourceService.loadVehicleEvents( - UnifiedVehicleEventsRequest.forTachographFileSession( - request.sessionId(), - vehicleRef.sourceVehicleEntityId(), - vehicleRef.vin(), - vehicleRef.registrationNation(), - vehicleRef.registrationNumber(), - request.vehicleOccurredFrom(), - request.vehicleOccurredTo() - ) - ); + List result = new ArrayList<>(); + for (UUID sessionId : resolveSessionIds(request)) { + result.addAll(vehicleEventSourceService.loadVehicleEvents( + UnifiedVehicleEventsRequest.forTachographFileSession( + sessionId, + vehicleRef.sourceVehicleEntityId(), + vehicleRef.vin(), + vehicleRef.registrationNation(), + vehicleRef.registrationNumber(), + request.vehicleOccurredFrom(), + request.vehicleOccurredTo() + ) + )); + } + return deduplicateBySignatureAndSort(result); + } + + private List resolveSessionIds(UnifiedRuntimeProcessingRequest request) { + if (request.compositeSessionId() != null) { + return compositeSessionRepository.find(request.compositeSessionId()) + .orElseThrow(() -> new TachographCompositeSessionNotFoundException(request.compositeSessionId())) + .memberSessionIds(); + } + return request.sessionIds(); + } + + private List deduplicateBySignatureAndSort(List events) { + LinkedHashMap bySignature = new LinkedHashMap<>(); + for (EventHubEventDto event : events) { + bySignature.putIfAbsent(eventKeyService.buildEventSignatureHash(event), event); + } + return eventSorter.sort(new ArrayList<>(bySignature.values())); } } diff --git a/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverEventSource.java b/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverEventSource.java index 016c899..7a277bb 100644 --- a/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverEventSource.java +++ b/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverEventSource.java @@ -36,6 +36,12 @@ public class TachographFileSessionUnifiedDriverEventSource implements UnifiedDri public List loadDriverEvents(UnifiedDriverEventsRequest request) { TachographFileSession session = repository.find(request.sessionId()) .orElseThrow(() -> new TachographFileSessionNotFoundException(request.sessionId())); + if (request.driverKey() == null) { + return session.driversByKey().values().stream() + .flatMap(driver -> eventBuilder.buildEvents(session, driver).stream()) + .filter(event -> withinWindow(event.occurredAt(), request.occurredFrom(), request.occurredTo())) + .toList(); + } DriverExtractionSession driver = session.driversByKey().get(request.driverKey()); if (driver == null) { throw new DriverNotFoundInSessionException(request.sessionId(), request.driverKey()); diff --git a/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeDerivedProjectionService.java b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeDerivedProjectionService.java new file mode 100644 index 0000000..d5491d6 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeDerivedProjectionService.java @@ -0,0 +1,472 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.dto.DriverRefDto; +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto; +import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; +import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingDerivedProjectionBundle; +import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialInVehicleOvernightStayIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialInVehicleTripIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperSupportGeoEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent; +import at.procon.eventhub.tachographfilesession.service.DriverTimelineBuilder; +import at.procon.eventhub.tachographfilesession.service.DriverTimelineReusableProjectionBuilder; +import at.procon.eventhub.tachographfilesession.service.TachographEsperProcessingCore; +import at.procon.eventhub.tachographfilesession.service.TachographEsperProcessingInput; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class UnifiedRuntimeDerivedProjectionService { + + private final UnifiedRuntimeEventAssemblyService runtimeEventAssemblyService; + private final UnifiedEventTimelineReconstructor timelineReconstructor; + private final DriverTimelineBuilder driverTimelineBuilder; + private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder; + private final TachographEsperProcessingCore esperProcessingCore; + private final EventHubProperties properties; + + public UnifiedRuntimeDerivedProjectionService( + UnifiedRuntimeEventAssemblyService runtimeEventAssemblyService, + UnifiedEventTimelineReconstructor timelineReconstructor, + DriverTimelineBuilder driverTimelineBuilder, + DriverTimelineReusableProjectionBuilder reusableProjectionBuilder, + EventHubProperties properties + ) { + this( + runtimeEventAssemblyService, + timelineReconstructor, + driverTimelineBuilder, + reusableProjectionBuilder, + properties, + new TachographEsperProcessingCore(driverTimelineBuilder, reusableProjectionBuilder, properties) + ); + } + + @Autowired + public UnifiedRuntimeDerivedProjectionService( + UnifiedRuntimeEventAssemblyService runtimeEventAssemblyService, + UnifiedEventTimelineReconstructor timelineReconstructor, + DriverTimelineBuilder driverTimelineBuilder, + DriverTimelineReusableProjectionBuilder reusableProjectionBuilder, + EventHubProperties properties, + TachographEsperProcessingCore esperProcessingCore + ) { + this.runtimeEventAssemblyService = runtimeEventAssemblyService; + this.timelineReconstructor = timelineReconstructor; + this.driverTimelineBuilder = driverTimelineBuilder; + this.reusableProjectionBuilder = reusableProjectionBuilder; + this.properties = properties; + this.esperProcessingCore = esperProcessingCore; + } + + public UnifiedRuntimeDerivedProjectionResultDto loadDriverDerivedProjections( + UnifiedRuntimeProcessingApiRequest apiRequest + ) { + UnifiedRuntimeProcessingRequest request = apiRequest.toRuntimeRequest(); + UnifiedRuntimeEventBundle eventBundle = runtimeEventAssemblyService.assembleDriverScopedEvents(request); + return buildDriverDerivedProjection(apiRequest, request, eventBundle, null); + } + + public UnifiedRuntimeDerivedProjectionResultDto buildDriverDerivedProjection( + UnifiedRuntimeProcessingApiRequest apiRequest, + UnifiedRuntimeProcessingRequest request, + UnifiedRuntimeEventBundle eventBundle, + String explicitDriverKey + ) { + String driverKey = explicitDriverKey == null + ? resolveDriverKey(request, eventBundle.mergedEvents()) + : explicitDriverKey; + ResolvedDriverTimeline timeline = timelineReconstructor.reconstruct( + runtimeSessionId(request), + driverKey, + eventBundle.mergedEvents() + ); + + OffsetDateTime requestedFrom = apiRequest.occurredFrom() == null + ? timeline.loadedFrom() + : utc(apiRequest.occurredFrom()); + OffsetDateTime requestedTo = apiRequest.occurredTo() == null + ? timeline.loadedTo() + : utc(apiRequest.occurredTo()); + if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) { + throw new IllegalArgumentException("occurredTo must not be before occurredFrom."); + } + + int significantDrivingMinutes = apiRequest.significantDrivingMinutes() == null + ? processingProperties().getSignificantDrivingMinutes() + : Math.max(1, apiRequest.significantDrivingMinutes()); + int minimumRestPeriodMinutes = apiRequest.minimumRestPeriodMinutes() == null + ? processingProperties().getMinimumRestPeriodMinutes() + : Math.max(1, apiRequest.minimumRestPeriodMinutes()); + + List notes = new ArrayList<>(eventBundle.notes()); + notes.add("Runtime derived projections were evaluated from the unified merged event stream using the shared tachograph Esper processing core."); + notes.add("Significant driving threshold minutes: " + significantDrivingMinutes + "."); + notes.add("Minimum rest candidate period minutes: " + minimumRestPeriodMinutes + "."); + if (request.occurredFrom() != null || request.occurredTo() != null) { + notes.add("Projection results are filtered to the requested runtime window. For intervals crossing the boundary, include enough source-event padding in the request."); + } + + TachographEsperDriverProcessingResultDto projection = esperProcessingCore.process(TachographEsperProcessingInput.fromEvents( + runtimeSessionId(request), + driverKey, + timeline, + eventBundle.mergedEvents(), + requestedFrom, + requestedTo, + significantDrivingMinutes, + minimumRestPeriodMinutes, + notes + )); + notes = projection.notes(); + + return new UnifiedRuntimeDerivedProjectionResultDto( + request, + eventBundle.driverSeedEvents().size(), + eventBundle.discoveredVehicles().size(), + eventBundle.expandedVehicleEvents().size(), + eventBundle.mergedEvents().size(), + eventBundle.discoveredVehicles(), + projection, + notes + ); + } + + private EventHubProperties.Processing processingProperties() { + return properties.getTachographFileSession().getProcessing(); + } + + private UUID runtimeSessionId(UnifiedRuntimeProcessingRequest request) { + if (request.compositeSessionId() != null || request.sessionIds().size() > 1) { + return null; + } + return request.sessionIds().size() == 1 ? request.sessionIds().get(0) : request.sessionId(); + } + + private String resolveDriverKey( + UnifiedRuntimeProcessingRequest request, + List events + ) { + if (request.driverKey() != null) { + return request.driverKey(); + } + if (request.driverSourceEntityId() != null) { + return request.driverSourceEntityId(); + } + for (EventHubEventDto event : events) { + DriverRefDto driverRef = event.driverRef(); + if (driverRef != null && driverRef.hasAnyReference()) { + return driverRef.stableKey(); + } + } + if (request.driverCardNation() != null && request.driverCardNumber() != null) { + return request.driverCardNation() + ":" + request.driverCardNumber(); + } + return request.driverCardNumber(); + } + + + private List mergeVehicleUsageIntervals( + List intervals + ) { + if (intervals == null || intervals.isEmpty()) { + return List.of(); + } + List sorted = intervals.stream() + .sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt) + .thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(TachographEsperVehicleUsageIntervalEvent::intervalId, Comparator.nullsLast(String::compareTo))) + .toList(); + List merged = new ArrayList<>(); + for (TachographEsperVehicleUsageIntervalEvent next : sorted) { + if (merged.isEmpty()) { + merged.add(next); + continue; + } + TachographEsperVehicleUsageIntervalEvent current = merged.get(merged.size() - 1); + if (canMergeVehicleUsage(current, next)) { + merged.set(merged.size() - 1, mergeVehicleUsage(current, next)); + } else { + merged.add(next); + } + } + return List.copyOf(merged); + } + + private boolean canMergeVehicleUsage( + TachographEsperVehicleUsageIntervalEvent left, + TachographEsperVehicleUsageIntervalEvent right + ) { + if (left == null || right == null || left.endedAt() == null || right.startedAt() == null) { + return false; + } + return Objects.equals(left.driverKey(), right.driverKey()) + && Objects.equals(left.registrationKey(), right.registrationKey()) + && Objects.equals(left.vehicleKey(), right.vehicleKey()) + && !right.startedAt().isAfter(left.endedAt().plusSeconds(1)); + } + + private TachographEsperVehicleUsageIntervalEvent mergeVehicleUsage( + TachographEsperVehicleUsageIntervalEvent left, + TachographEsperVehicleUsageIntervalEvent right + ) { + List sourceIntervalIds = new ArrayList<>(); + if (left.sourceIntervalIds() != null) { + sourceIntervalIds.addAll(left.sourceIntervalIds()); + } + if (right.sourceIntervalIds() != null) { + for (String sourceIntervalId : right.sourceIntervalIds()) { + if (!sourceIntervalIds.contains(sourceIntervalId)) { + sourceIntervalIds.add(sourceIntervalId); + } + } + } + OffsetDateTime end = right.endedAt() == null || right.endedAt().isBefore(left.endedAt()) + ? left.endedAt() + : right.endedAt(); + return new TachographEsperVehicleUsageIntervalEvent( + left.sessionId(), + left.driverKey(), + left.intervalId(), + left.startedAt(), + end, + Duration.between(left.startedAt(), end).getSeconds(), + left.odometerBeginKm(), + right.odometerEndKm() == null ? left.odometerEndKm() : right.odometerEndKm(), + left.registrationKey(), + left.vehicleKey(), + left.sourceKind(), + sourceIntervalIds + ); + } + + private List clipActivityIntervals( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return intervals == null ? List.of() : List.copyOf(intervals); + } + return (intervals == null ? List.of() : intervals).stream() + .map(interval -> { + if (!intersects(interval.startedAt(), interval.endedAt(), requestedFrom, requestedTo)) { + return null; + } + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (start == null || end == null || !end.isAfter(start)) { + return null; + } + boolean clipped = interval.clippedToRequestedPeriod() + || !Objects.equals(start, interval.startedAt()) + || !Objects.equals(end, interval.endedAt()); + return new TachographEsperActivityIntervalEvent( + interval.sessionId(), + interval.driverKey(), + interval.intervalId(), + interval.activityType(), + interval.cardSlot(), + interval.cardStatus(), + interval.drivingStatus(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.sourceIntervalIds(), + interval.synthetic(), + clipped, + interval.level() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperActivityIntervalEvent::startedAt) + .thenComparing(TachographEsperActivityIntervalEvent::endedAt)) + .toList(); + } + + private List clipVehicleUsageIntervals( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return intervals == null ? List.of() : List.copyOf(intervals); + } + return (intervals == null ? List.of() : intervals).stream() + .map(interval -> { + if (!intersects(interval.startedAt(), interval.endedAt(), requestedFrom, requestedTo)) { + return null; + } + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (start == null || end == null || !end.isAfter(start)) { + return null; + } + boolean startClipped = !Objects.equals(start, interval.startedAt()); + boolean endClipped = !Objects.equals(end, interval.endedAt()); + return new TachographEsperVehicleUsageIntervalEvent( + interval.sessionId(), + interval.driverKey(), + interval.intervalId(), + start, + end, + Duration.between(start, end).getSeconds(), + startClipped ? null : interval.odometerBeginKm(), + endClipped ? null : interval.odometerEndKm(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + interval.sourceIntervalIds() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt) + .thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt)) + .toList(); + } + + private List clipDrivingIntervals( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + return filterIntersecting( + intervals, + requestedFrom, + requestedTo, + TachographEsperDrivingInterruptionIntervalEvent::startedAt, + TachographEsperDrivingInterruptionIntervalEvent::endedAt + ); + } + + private List clipVuCardAbsentIntervals( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + return filterIntersecting( + intervals, + requestedFrom, + requestedTo, + TachographEsperVuCardAbsentIntervalEvent::startedAt, + TachographEsperVuCardAbsentIntervalEvent::endedAt + ); + } + + private List clipSupportGeoEvents( + List supportEvents, + String driverKey, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + return (supportEvents == null ? List.of() : supportEvents).stream() + .filter(event -> event.occurredAt() != null) + .filter(event -> driverKey == null || event.driverKey() == null || Objects.equals(driverKey, event.driverKey())) + .filter(event -> requestedFrom == null || !event.occurredAt().isBefore(requestedFrom)) + .filter(event -> requestedTo == null || !event.occurredAt().isAfter(requestedTo)) + .map(event -> new TachographEsperSupportGeoEvent( + event.eventId(), + event.driverKey(), + event.occurredAt(), + event.eventDomain(), + event.eventType(), + event.eventLifecycle(), + event.registrationKey(), + event.vehicleKey(), + event.country(), + event.region(), + event.countryFrom(), + event.countryTo(), + event.operation(), + event.latitude(), + event.longitude(), + event.odometerKm(), + event.rawRecordPath() + )) + .sorted(Comparator.comparing(TachographEsperSupportGeoEvent::occurredAt) + .thenComparing(TachographEsperSupportGeoEvent::eventId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List filterIntersecting( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo, + TimeAccessor startAccessor, + TimeAccessor endAccessor + ) { + if (intervals == null || intervals.isEmpty()) { + return List.of(); + } + if (requestedFrom == null || requestedTo == null) { + return List.copyOf(intervals); + } + return intervals.stream() + .filter(interval -> intersects(startAccessor.get(interval), endAccessor.get(interval), requestedFrom, requestedTo)) + .toList(); + } + + private boolean intersects( + OffsetDateTime intervalStart, + OffsetDateTime intervalEnd, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (intervalStart == null || intervalEnd == null || requestedFrom == null || requestedTo == null) { + return false; + } + return intervalEnd.isAfter(requestedFrom) && intervalStart.isBefore(requestedTo); + } + + private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isBefore(right) ? left : right; + } + + private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isAfter(right) ? left : right; + } + + private OffsetDateTime utc(OffsetDateTime value) { + return value == null ? null : value.withOffsetSameInstant(ZoneOffset.UTC); + } + + @FunctionalInterface + private interface TimeAccessor { + OffsetDateTime get(T value); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeEventAssemblyService.java b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeEventAssemblyService.java index eeaef36..3916abc 100644 --- a/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeEventAssemblyService.java +++ b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeEventAssemblyService.java @@ -40,6 +40,15 @@ public class UnifiedRuntimeEventAssemblyService { notes.add(request.eventBackend() == UnifiedRuntimeEventBackend.EVENTHUB_DB ? "Driver seed events were loaded from the local EventHub event store." : "Driver seed events were loaded directly from the selected runtime sources."); + if (request.sourceFamilies().contains(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION)) { + if (request.compositeSessionId() != null) { + notes.add("Tachograph file-session events were loaded from composite session " + request.compositeSessionId() + "."); + } else if (request.sessionIds().size() > 1) { + notes.add("Tachograph file-session events were loaded from " + request.sessionIds().size() + " selected sessions."); + } else if (request.sessionId() != null) { + notes.add("Tachograph file-session events were loaded from session " + request.sessionId() + "."); + } + } if (request.expandVehicleEvents()) { notes.add("Vehicle expansion loaded additional events for vehicles discovered in the driver seed set."); notes.add("Vehicle expansion padding minutes: " + request.vehicleExpansionPaddingMinutes() + "."); diff --git a/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeTachographEsperScopeProcessingService.java b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeTachographEsperScopeProcessingService.java new file mode 100644 index 0000000..276a85e --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeTachographEsperScopeProcessingService.java @@ -0,0 +1,293 @@ +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.UnifiedRuntimeDerivedProjectionResultDto; +import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.dto.UnifiedRuntimeTachographEsperScopeResultDto; +import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef; +import at.procon.eventhub.processing.model.RuntimeDriverVehicleEvidenceAttachmentResult; +import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.springframework.stereotype.Service; + +@Service +public class UnifiedRuntimeTachographEsperScopeProcessingService { + + private final UnifiedRuntimeEventAssemblyService eventAssemblyService; + private final UnifiedRuntimeDerivedProjectionService derivedProjectionService; + private final RuntimeDriverVehicleEvidenceAttachmentService vehicleEvidenceAttachmentService; + + public UnifiedRuntimeTachographEsperScopeProcessingService( + UnifiedRuntimeEventAssemblyService eventAssemblyService, + UnifiedRuntimeDerivedProjectionService derivedProjectionService, + RuntimeDriverVehicleEvidenceAttachmentService vehicleEvidenceAttachmentService + ) { + this.eventAssemblyService = eventAssemblyService; + this.derivedProjectionService = derivedProjectionService; + this.vehicleEvidenceAttachmentService = vehicleEvidenceAttachmentService; + } + + public UnifiedRuntimeTachographEsperScopeResultDto processScope(UnifiedRuntimeProcessingApiRequest apiRequest) { + UnifiedRuntimeProcessingRequest request = apiRequest.toRuntimeRequest(); + UnifiedRuntimeEventBundle broadBundle = eventAssemblyService.assembleDriverScopedEvents(request); + LinkedHashSet selectedDriverKeys = selectedDriverKeys(request, broadBundle.mergedEvents()); + if (selectedDriverKeys.isEmpty()) { + throw new IllegalArgumentException("No driver partitions could be resolved from the runtime event scope."); + } + + Map driverResults = new LinkedHashMap<>(); + Map> attachedVehicleEvidenceByEvent = new LinkedHashMap<>(); + List warnings = new ArrayList<>(); + for (String driverKey : selectedDriverKeys) { + UnifiedRuntimeEventBundle driverBundle = partitionForDriver(request, broadBundle, driverKey); + for (EventHubEventDto attachedEvent : driverBundle.expandedVehicleEvents()) { + attachedVehicleEvidenceByEvent + .computeIfAbsent(dedupKey(attachedEvent), ignored -> new ArrayList<>()) + .add(driverKey); + } + driverBundle.notes().stream() + .filter(note -> note.startsWith("WARNING:")) + .map(note -> note.substring("WARNING:".length()).trim()) + .forEach(warnings::add); + if (driverBundle.mergedEvents().isEmpty()) { + warnings.add("No events remained after partitioning runtime scope for driver " + driverKey + "."); + continue; + } + UnifiedRuntimeProcessingRequest driverRequest = request.withDriverKey(driverKey); + UnifiedRuntimeDerivedProjectionResultDto driverResult = derivedProjectionService.buildDriverDerivedProjection( + apiRequest, + driverRequest, + driverBundle, + driverKey + ); + driverResults.put(driverKey, driverResult); + } + attachedVehicleEvidenceByEvent.forEach((eventKey, drivers) -> { + if (drivers.size() > 1) { + warnings.add("Vehicle-only event " + eventKey + " was attached to multiple driver partitions " + + drivers + "; check overlapping vehicle-usage intervals."); + } + }); + + List notes = new ArrayList<>(broadBundle.notes()); + notes.add("Runtime tachograph Esper scope processing used Java-side driver partitioning before calling the common event-input Esper pipeline."); + notes.add("Selected driver partitions: " + selectedDriverKeys.size() + "."); + if (!request.includeAllDrivers() && !request.driverKeys().isEmpty()) { + notes.add("The broad runtime event set was filtered to the requested driverKeys."); + } + if (!request.vehicleKeys().isEmpty() || request.includeAllVehicles()) { + notes.add("vehicleKeys/includeAllVehicles are accepted in the request model for source-neutral scopes; driver partitions are enriched only with vehicle evidence that overlaps reconstructed driver vehicle-usage intervals."); + } + notes.add("Vehicle-only evidence attachment is controlled by expandVehicleEvents/attachVehicleOnlyEvents and vehicleExpansionPaddingMinutes/vehicleEvidencePaddingMinutes."); + + return new UnifiedRuntimeTachographEsperScopeResultDto( + request, + broadBundle.mergedEvents().size(), + driverResults.size(), + broadBundle.discoveredVehicles().size(), + broadBundle.discoveredVehicles(), + driverResults, + notes, + warnings + ); + } + + private LinkedHashSet selectedDriverKeys( + UnifiedRuntimeProcessingRequest request, + List events + ) { + LinkedHashSet allDrivers = discoverDriverKeys(events); + if (request.includeAllDrivers()) { + return allDrivers; + } + LinkedHashSet requested = new LinkedHashSet<>(request.driverKeys()); + if (request.driverKey() != null) { + requested.add(request.driverKey()); + } + if (requested.isEmpty()) { + return allDrivers; + } + LinkedHashSet selected = new LinkedHashSet<>(); + for (String driverKey : allDrivers) { + if (requested.contains(driverKey)) { + selected.add(driverKey); + } + } + for (String driverKey : requested) { + selected.add(driverKey); + } + return selected; + } + + private UnifiedRuntimeEventBundle partitionForDriver( + UnifiedRuntimeProcessingRequest request, + UnifiedRuntimeEventBundle broadBundle, + String driverKey + ) { + List directDriverEvents = broadBundle.mergedEvents().stream() + .filter(event -> Objects.equals(driverKey(event), driverKey)) + .toList(); + RuntimeDriverVehicleEvidenceAttachmentResult attachmentResult = vehicleEvidenceAttachmentService.attachVehicleEvidence( + driverKey, + directDriverEvents, + broadBundle.mergedEvents(), + request.expandVehicleEvents(), + request.vehicleExpansionPaddingMinutes() + ); + List driverVehicles = discoverVehicles(attachmentResult.mergedEvents()); + List notes = new ArrayList<>(broadBundle.notes()); + notes.add("Partitioned mixed runtime event scope for driver " + driverKey + "."); + notes.add("Driver direct events: " + attachmentResult.directDriverEvents().size() + "."); + notes.add("Vehicle-only evidence events attached to driver partition: " + attachmentResult.attachedVehicleEvidenceEvents().size() + "."); + 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( + request.withDriverKey(driverKey), + attachmentResult.directDriverEvents(), + driverVehicles, + attachmentResult.attachedVehicleEvidenceEvents(), + attachmentResult.mergedEvents(), + notes + ); + } + + private LinkedHashSet discoverDriverKeys(List events) { + LinkedHashSet result = new LinkedHashSet<>(); + for (EventHubEventDto event : sort(events)) { + String driverKey = driverKey(event); + if (driverKey != null) { + result.add(driverKey); + } + } + return result; + } + + private List discoverVehicles(List events) { + List result = new ArrayList<>(); + for (EventHubEventDto event : events) { + UnifiedDiscoveredVehicleRef candidate = vehicleRef(event.vehicleRef()); + if (candidate == null || !candidate.hasAnyReference()) { + continue; + } + boolean merged = false; + for (int i = 0; i < result.size(); i++) { + UnifiedDiscoveredVehicleRef existing = result.get(i); + if (existing.matches(candidate)) { + result.set(i, existing.merge(candidate)); + merged = true; + break; + } + } + if (!merged) { + result.add(candidate); + } + } + result.sort(Comparator.comparing(UnifiedDiscoveredVehicleRef::stableKey)); + return List.copyOf(result); + } + + private boolean matchesAnyVehicle(VehicleRefDto vehicleRef, List vehicles) { + UnifiedDiscoveredVehicleRef candidate = vehicleRef(vehicleRef); + if (candidate == null || !candidate.hasAnyReference()) { + return false; + } + return vehicles.stream().anyMatch(vehicle -> vehicle.matches(candidate)); + } + + private UnifiedDiscoveredVehicleRef vehicleRef(VehicleRefDto vehicleRef) { + if (vehicleRef == null || !vehicleRef.hasAnyReference()) { + return null; + } + return new UnifiedDiscoveredVehicleRef( + vehicleRef.sourceVehicleEntityId(), + vehicleRef.vin(), + vehicleRef.vehicleRegistration() == null + ? null + : vehicleRef.vehicleRegistration().nationNumericCode() == null + ? vehicleRef.vehicleRegistration().nation() + : vehicleRef.vehicleRegistration().nationNumericCode().toString(), + vehicleRef.vehicleRegistration() == null ? null : vehicleRef.vehicleRegistration().number() + ); + } + + private List deduplicateAndSort( + List directDriverEvents, + List vehicleEvidenceEvents + ) { + LinkedHashMap byKey = new LinkedHashMap<>(); + appendDeduplicated(byKey, directDriverEvents); + appendDeduplicated(byKey, vehicleEvidenceEvents); + return sort(new ArrayList<>(byKey.values())); + } + + private void appendDeduplicated(LinkedHashMap byKey, List events) { + for (EventHubEventDto event : events) { + byKey.putIfAbsent(dedupKey(event), event); + } + } + + private String dedupKey(EventHubEventDto event) { + String sourceKey = event.packageInfo() != null && event.packageInfo().eventSource() != null + ? event.packageInfo().eventSource().stableKey() + : "NO_SOURCE"; + return sourceKey + "|" + event.externalSourceEventId(); + } + + private List sort(List events) { + return (events == null ? List.of() : events).stream() + .sorted(Comparator.comparing(EventHubEventDto::occurredAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(event -> event.eventDomain() == null ? "" : event.eventDomain().name()) + .thenComparing(event -> event.eventType() == null ? "" : event.eventType().name()) + .thenComparing(event -> event.lifecycle() == null ? "" : event.lifecycle().name()) + .thenComparing(EventHubEventDto::externalSourceEventId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private String driverKey(EventHubEventDto event) { + if (event == null) { + return null; + } + String rawDriverKey = text(rawPayload(event), "driverKey"); + if (rawDriverKey != null) { + return rawDriverKey; + } + DriverRefDto driverRef = event.driverRef(); + if (driverRef != null && driverRef.hasAnyReference()) { + return driverRef.stableKey(); + } + return null; + } + + private JsonNode rawPayload(EventHubEventDto event) { + JsonNode payload = event.payload(); + if (payload == null || payload.isNull()) { + return null; + } + JsonNode raw = payload.get("raw"); + return raw == null || raw.isNull() ? payload : raw; + } + + private String text(JsonNode node, String field) { + if (node == null || field == null) { + return null; + } + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + String text = value.asText(null); + return text == null || text.isBlank() ? null : text.trim(); + } +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineReusableProjectionBuilder.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineReusableProjectionBuilder.java index b4e270c..6a24b1a 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineReusableProjectionBuilder.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineReusableProjectionBuilder.java @@ -61,26 +61,23 @@ public class DriverTimelineReusableProjectionBuilder { private final DriverTimelineBuilder driverTimelineBuilder; private final RawSourceDriverTimelineEventBuilder rawSourceEventBuilder; - private final UnifiedEventTimelineReconstructor timelineReconstructor; private final EventHubProperties properties; public DriverTimelineReusableProjectionBuilder( DriverTimelineBuilder driverTimelineBuilder, EventHubProperties properties ) { - this(driverTimelineBuilder, null, new UnifiedEventTimelineReconstructor(), properties); + this(driverTimelineBuilder, null, properties); } @Autowired public DriverTimelineReusableProjectionBuilder( DriverTimelineBuilder driverTimelineBuilder, RawSourceDriverTimelineEventBuilder rawSourceEventBuilder, - UnifiedEventTimelineReconstructor timelineReconstructor, EventHubProperties properties ) { this.driverTimelineBuilder = driverTimelineBuilder; this.rawSourceEventBuilder = rawSourceEventBuilder; - this.timelineReconstructor = timelineReconstructor; this.properties = properties; } @@ -325,20 +322,17 @@ public class DriverTimelineReusableProjectionBuilder { String fallbackDriverKey, List events ) { + UnifiedEventTimelineReconstructor timelineReconstructor = new UnifiedEventTimelineReconstructor(); ResolvedDriverTimeline reconstructed = timelineReconstructor.reconstruct( fallbackSessionId == null ? new UUID(0L, 0L) : fallbackSessionId, fallbackDriverKey, safeList(events) ); - List mergedVehicleUsageIntervals = mergeVehicleUsageIntervals( - reconstructed.vehicleUsageIntervals(), - reconstructed.sourceKind() - ); return new ResolvedDriverTimeline( reconstructed.sourceKind(), reconstructed.loadedFrom(), reconstructed.loadedTo(), - mergedVehicleUsageIntervals, + mergeVehicleUsageIntervals(reconstructed.vehicleUsageIntervals(), reconstructed.sourceKind()), reconstructed.activityIntervals(), reconstructed.supportEvents(), reconstructed.warnings() @@ -411,7 +405,105 @@ public class DriverTimelineReusableProjectionBuilder { int significantDrivingMinutes, int minimumRestPeriodMinutes ) { - throw new UnsupportedOperationException("Direct EPL point-input preprocessing is currently disabled."); + if ((activityPointInputEvents == null || activityPointInputEvents.isEmpty()) + && (vehicleUsagePointInputEvents == null || vehicleUsagePointInputEvents.isEmpty())) { + return emptyBundle(); + } + + List drivingInterruptionIntervals = new ArrayList<>(); + List dailyWeeklyRestCandidateIntervals = new ArrayList<>(); + List dailyWeeklyRestCandidateCoverageIntervals = new ArrayList<>(); + List unclassifiedDailyWeeklyRestCandidateCoverageIntervals = new ArrayList<>(); + List drivingInterruptionVehicleChangeIntervals = new ArrayList<>(); + List vuCardAbsentIntervals = new ArrayList<>(); + List potentialHomeOvernightStayIntervals = new ArrayList<>(); + List potentialInVehicleOvernightStayIntervals = new ArrayList<>(); + List potentialInVehicleTripIntervals = new ArrayList<>(); + + executeWithRuntime( + configuration -> { + configuration.getCommon().addEventType( + "TachographActivityPointInputEvent", + activityPointInputDefinition() + ); + configuration.getCommon().addEventType( + "TachographVehicleUsagePointInputEvent", + vehicleUsagePointInputDefinition() + ); + configuration.getCommon().addEventType( + "TachographProjectionFinalizeEvent", + projectionFinalizeInputDefinition() + ); + configuration.getCommon().addEventType( + "TachographActivityIntervalInputEvent", + activityIntervalInputDefinition() + ); + configuration.getCommon().addEventType( + "TachographVehicleUsageIntervalInputEvent", + vehicleUsageIntervalInputDefinition() + ); + configuration.getCommon().addEventType( + "TachographSupportGeoEvidenceInputEvent", + supportGeoEvidenceInputDefinition() + ); + }, + renderDrivingDerivedProjectionEventsEpl(significantDrivingMinutes, minimumRestPeriodMinutes), + Map.of( + "drivingInterruptionIntervals", newData -> collectDrivingInterruptionIntervalEvents(newData, drivingInterruptionIntervals), + "dailyWeeklyRestCandidateIntervals", newData -> collectDrivingInterruptionIntervalEvents(newData, dailyWeeklyRestCandidateIntervals), + "dailyWeeklyRestCandidateCoverageIntervals", newData -> collectDailyWeeklyRestCandidateCoverageIntervalEvents(newData, dailyWeeklyRestCandidateCoverageIntervals), + "unclassifiedDailyWeeklyRestCandidateCoverageIntervals", newData -> collectDailyWeeklyRestCandidateCoverageIntervalEvents(newData, unclassifiedDailyWeeklyRestCandidateCoverageIntervals), + "drivingInterruptionVehicleChangeIntervals", newData -> collectDrivingInterruptionIntervalEvents(newData, drivingInterruptionVehicleChangeIntervals), + "vuCardAbsentIntervals", newData -> collectVuCardAbsentIntervalEvents(newData, vuCardAbsentIntervals), + "potentialHomeOvernightStayIntervals", newData -> collectPotentialHomeOvernightStayIntervalEvents(newData, potentialHomeOvernightStayIntervals), + "potentialInVehicleOvernightStayIntervals", newData -> collectPotentialInVehicleOvernightStayIntervalEvents(newData, potentialInVehicleOvernightStayIntervals), + "potentialInVehicleTripIntervals", newData -> collectPotentialInVehicleTripIntervalEvents(newData, potentialInVehicleTripIntervals) + ), + runtime -> { + if (supportGeoInputEvents != null) { + for (Map supportGeoEvidence : supportGeoInputEvents) { + runtime.getEventService().sendEventMap( + supportGeoEvidence, + "TachographSupportGeoEvidenceInputEvent" + ); + } + } + if (vehicleUsagePointInputEvents != null) { + for (Map point : vehicleUsagePointInputEvents) { + runtime.getEventService().sendEventMap( + point, + "TachographVehicleUsagePointInputEvent" + ); + } + for (Map finalizeEvent : buildProjectionFinalizeEvents(vehicleUsagePointInputEvents)) { + runtime.getEventService().sendEventMap( + finalizeEvent, + "TachographProjectionFinalizeEvent" + ); + } + } + if (activityPointInputEvents != null) { + for (Map point : activityPointInputEvents) { + runtime.getEventService().sendEventMap( + point, + "TachographActivityPointInputEvent" + ); + } + } + } + ); + + return new TachographEsperDrivingDerivedProjectionBundle( + sortDrivingInterruptionIntervals(drivingInterruptionIntervals), + sortDrivingInterruptionIntervals(dailyWeeklyRestCandidateIntervals), + sortDailyWeeklyRestCandidateCoverageIntervals(dailyWeeklyRestCandidateCoverageIntervals), + sortDailyWeeklyRestCandidateCoverageIntervals(unclassifiedDailyWeeklyRestCandidateCoverageIntervals), + sortDrivingInterruptionIntervals(drivingInterruptionVehicleChangeIntervals), + sortVuCardAbsentIntervals(vuCardAbsentIntervals), + sortPotentialHomeOvernightStayIntervals(potentialHomeOvernightStayIntervals), + sortPotentialInVehicleOvernightStayIntervals(potentialInVehicleOvernightStayIntervals), + sortPotentialInVehicleTripIntervals(potentialInVehicleTripIntervals) + ); } private List> buildProjectionFinalizeEvents( diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCore.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCore.java new file mode 100644 index 0000000..f71747c --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCore.java @@ -0,0 +1,760 @@ +package at.procon.eventhub.tachographfilesession.service; + +import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; +import at.procon.eventhub.tachographfilesession.model.*; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import org.springframework.stereotype.Service; + +@Service +public class TachographEsperProcessingCore { + + private final DriverTimelineBuilder driverTimelineBuilder; + private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder; + private final EventHubProperties properties; + + public TachographEsperProcessingCore( + DriverTimelineBuilder driverTimelineBuilder, + DriverTimelineReusableProjectionBuilder reusableProjectionBuilder, + EventHubProperties properties + ) { + this.driverTimelineBuilder = driverTimelineBuilder; + this.reusableProjectionBuilder = reusableProjectionBuilder; + this.properties = properties; + } + + public TachographEsperDriverProcessingResultDto process(TachographEsperProcessingInput input) { + Objects.requireNonNull(input, "input must not be null"); + ResolvedDriverTimeline timeline = Objects.requireNonNull(input.timeline(), "timeline must not be null"); + String driverKey = input.driverKey(); + OffsetDateTime requestedFrom = input.requestedFrom() == null ? timeline.loadedFrom() : utc(input.requestedFrom()); + OffsetDateTime requestedTo = input.requestedTo() == null ? timeline.loadedTo() : utc(input.requestedTo()); + if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) { + throw new IllegalArgumentException("occurredTo must not be before occurredFrom."); + } + int significantDrivingMinutes = Math.max(1, input.significantDrivingMinutes()); + int minimumRestPeriodMinutes = Math.max(1, input.minimumRestPeriodMinutes()); + + List activityIntervals = clipEsperActivityIntervalEvents( + driverTimelineBuilder.buildEsperActivityIntervalEvents(input.sessionId(), driverKey, timeline), + requestedFrom, + requestedTo + ); + List drivingIntervals = clipEsperActivityIntervalEvents( + driverTimelineBuilder.buildEsperDrivingIntervalEvents(input.sessionId(), driverKey, timeline), + requestedFrom, + requestedTo + ); + + TachographEsperDrivingDerivedProjectionBundle derivedProjectionBundle = buildDerivedProjection( + input, + timeline, + significantDrivingMinutes, + minimumRestPeriodMinutes + ); + + List rawDrivingInterruptionIntervals = + derivedProjectionBundle.drivingInterruptionIntervals(); + List drivingInterruptionIntervals = + clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionIntervals, requestedFrom, requestedTo); + List rawDailyWeeklyRestCandidateIntervals = + derivedProjectionBundle.dailyWeeklyRestCandidateIntervals(); + List dailyWeeklyRestCandidateIntervals = + clipEsperDrivingInterruptionIntervalEvents(rawDailyWeeklyRestCandidateIntervals, requestedFrom, requestedTo); + List rawDrivingInterruptionVehicleChangeIntervals = + derivedProjectionBundle.drivingInterruptionVehicleChangeIntervals(); + List drivingInterruptionVehicleChangeIntervals = + clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionVehicleChangeIntervals, requestedFrom, requestedTo); + + List rawVehicleUsageIntervals = + driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline); + List rawVuCardAbsentIntervals = + derivedProjectionBundle.vuCardAbsentIntervals(); + + List potentialHomeOvernightStayIntervals = + clipEsperPotentialHomeOvernightStayIntervalEvents( + derivedProjectionBundle.potentialHomeOvernightStayIntervals(), + rawVuCardAbsentIntervals, + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List dailyWeeklyRestCandidateCoverageIntervals = + clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( + derivedProjectionBundle.dailyWeeklyRestCandidateCoverageIntervals(), + rawVuCardAbsentIntervals, + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List unclassifiedDailyWeeklyRestCandidateCoverageIntervals = + clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( + derivedProjectionBundle.unclassifiedDailyWeeklyRestCandidateCoverageIntervals(), + rawVuCardAbsentIntervals, + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List potentialInVehicleOvernightStayIntervals = + clipEsperPotentialInVehicleOvernightStayIntervalEvents( + derivedProjectionBundle.potentialInVehicleOvernightStayIntervals(), + rawVuCardAbsentIntervals, + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List potentialInVehicleTripIntervals = + clipEsperPotentialInVehicleTripIntervalEvents( + derivedProjectionBundle.potentialInVehicleTripIntervals(), + potentialInVehicleOvernightStayIntervals, + requestedFrom, + requestedTo + ); + List vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents( + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents( + rawVuCardAbsentIntervals, + requestedFrom, + requestedTo + ); + List supportGeoEvents = clipEsperSupportGeoEvents( + timeline.supportEvents(), + driverKey, + requestedFrom, + requestedTo + ); + + return new TachographEsperDriverProcessingResultDto( + input.sessionId(), + driverKey, + timeline.sourceKind(), + timeline.loadedFrom(), + timeline.loadedTo(), + requestedFrom, + requestedTo, + activityIntervals.size(), + drivingIntervals.size(), + drivingInterruptionIntervals.size(), + drivingInterruptionVehicleChangeIntervals.size(), + dailyWeeklyRestCandidateIntervals.size(), + dailyWeeklyRestCandidateCoverageIntervals.size(), + unclassifiedDailyWeeklyRestCandidateCoverageIntervals.size(), + potentialHomeOvernightStayIntervals.size(), + potentialInVehicleOvernightStayIntervals.size(), + potentialInVehicleTripIntervals.size(), + vehicleUsageIntervals.size(), + vuCardAbsentIntervals.size(), + supportGeoEvents.size(), + activityIntervals, + drivingIntervals, + drivingInterruptionIntervals, + drivingInterruptionVehicleChangeIntervals, + dailyWeeklyRestCandidateIntervals, + dailyWeeklyRestCandidateCoverageIntervals, + unclassifiedDailyWeeklyRestCandidateCoverageIntervals, + potentialHomeOvernightStayIntervals, + potentialInVehicleOvernightStayIntervals, + potentialInVehicleTripIntervals, + vehicleUsageIntervals, + vuCardAbsentIntervals, + supportGeoEvents, + combinedNotes(input.notes()) + ); + } + + private TachographEsperDrivingDerivedProjectionBundle buildDerivedProjection( + TachographEsperProcessingInput input, + ResolvedDriverTimeline timeline, + int significantDrivingMinutes, + int minimumRestPeriodMinutes + ) { + if (input.forceEventInput() || input.hasEventInputEvents()) { + return reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundleFromEvents( + input.sessionId(), + input.driverKey(), + input.eventInputEvents(), + significantDrivingMinutes, + minimumRestPeriodMinutes + ); + } + if (properties.getTachographFileSession().getProcessing().getDrivingDerivedProjectionInputMode() + == EventHubProperties.DrivingDerivedProjectionInputMode.EVENTS + && input.session() != null + && input.driverSession() != null) { + return reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle( + input.session(), + input.driverSession(), + significantDrivingMinutes, + minimumRestPeriodMinutes + ); + } + return reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle( + input.sessionId(), + input.driverKey(), + timeline, + significantDrivingMinutes, + minimumRestPeriodMinutes + ); + } + + private List combinedNotes(List extraNotes) { + List notes = new ArrayList<>(); + notes.addAll(esperProjectionNotes()); + if (extraNotes != null) { + notes.addAll(extraNotes); + } + return List.copyOf(notes); + } + + + private List clipEsperActivityIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + boolean clipped = interval.clippedToRequestedPeriod() + || !start.equals(interval.startedAt()) + || !end.equals(interval.endedAt()); + return new TachographEsperActivityIntervalEvent( + interval.sessionId(), + interval.driverKey(), + interval.intervalId(), + interval.activityType(), + interval.cardSlot(), + interval.cardStatus(), + interval.drivingStatus(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.sourceIntervalIds(), + interval.synthetic(), + clipped, + interval.level() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperActivityIntervalEvent::startedAt) + .thenComparing(TachographEsperActivityIntervalEvent::endedAt) + .thenComparing(TachographEsperActivityIntervalEvent::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List clipEsperVehicleUsageIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + boolean startClipped = !start.equals(interval.startedAt()); + boolean endClipped = !end.equals(interval.endedAt()); + return new TachographEsperVehicleUsageIntervalEvent( + interval.sessionId(), + interval.driverKey(), + interval.intervalId(), + start, + end, + Duration.between(start, end).getSeconds(), + startClipped ? null : interval.odometerBeginKm(), + endClipped ? null : interval.odometerEndKm(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + interval.sourceIntervalIds() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt) + .thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt) + .thenComparing(TachographEsperVehicleUsageIntervalEvent::intervalId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List clipEsperSupportGeoEvents( + List supportEvents, + String driverKey, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (supportEvents == null || supportEvents.isEmpty() || requestedFrom == null || requestedTo == null) { + return List.of(); + } + return supportEvents.stream() + .filter(event -> event.driverKey() == null || Objects.equals(driverKey, event.driverKey())) + .filter(event -> event.occurredAt() != null) + .filter(event -> event.latitude() != null && event.longitude() != null) + .filter(event -> !event.occurredAt().isBefore(requestedFrom) && !event.occurredAt().isAfter(requestedTo)) + .map(event -> new TachographEsperSupportGeoEvent( + event.eventId(), + event.driverKey(), + event.occurredAt(), + event.eventDomain(), + event.eventType(), + event.eventLifecycle(), + event.registrationKey(), + event.vehicleKey(), + event.country(), + event.region(), + event.countryFrom(), + event.countryTo(), + event.operation(), + event.latitude(), + event.longitude(), + event.odometerKm(), + event.rawRecordPath() + )) + .sorted(Comparator.comparing(TachographEsperSupportGeoEvent::occurredAt) + .thenComparing(TachographEsperSupportGeoEvent::eventDomain, Comparator.nullsLast(String::compareTo)) + .thenComparing(TachographEsperSupportGeoEvent::eventId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List clipEsperDrivingInterruptionIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + return new TachographEsperDrivingInterruptionIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.previousDrivingSourceIntervalId(), + interval.nextDrivingSourceIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperDrivingInterruptionIntervalEvent::startedAt) + .thenComparing(TachographEsperDrivingInterruptionIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperVuCardAbsentIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + return new TachographEsperVuCardAbsentIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.previousUsageIntervalId(), + interval.nextUsageIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperVuCardAbsentIntervalEvent::startedAt) + .thenComparing(TachographEsperVuCardAbsentIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( + List intervals, + List rawVuCardAbsentIntervals, + List rawVehicleUsageIntervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + long durationSeconds = Duration.between(start, end).getSeconds(); + boolean beginBoundaryChanged = !start.equals(interval.startedAt()); + boolean endBoundaryChanged = !end.equals(interval.endedAt()); + return new TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + durationSeconds, + interval.cardAbsentDurationSeconds(), + interval.cardAbsentCoveragePercent(), + interval.previousDrivingSourceIntervalId(), + interval.nextDrivingSourceIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey(), + beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(), + endBoundaryChanged ? null : interval.endBoundaryOdometerKm(), + beginBoundaryChanged ? null : interval.beginGeoEventId(), + beginBoundaryChanged ? null : interval.beginGeoEventDomain(), + beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), + beginBoundaryChanged ? null : interval.beginLatitude(), + beginBoundaryChanged ? null : interval.beginLongitude(), + beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(), + beginBoundaryChanged ? null : interval.beginGeoOdometerKm(), + endBoundaryChanged ? null : interval.endGeoEventId(), + endBoundaryChanged ? null : interval.endGeoEventDomain(), + endBoundaryChanged ? null : interval.endGeoOccurredAt(), + endBoundaryChanged ? null : interval.endLatitude(), + endBoundaryChanged ? null : interval.endLongitude(), + endBoundaryChanged ? null : interval.endGeoDistanceSeconds(), + endBoundaryChanged ? null : interval.endGeoOdometerKm(), + beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(), + beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(), + beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()), + endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm()) + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::startedAt) + .thenComparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperPotentialHomeOvernightStayIntervalEvents( + List intervals, + List rawVuCardAbsentIntervals, + List rawVehicleUsageIntervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + long durationSeconds = Duration.between(start, end).getSeconds(); + boolean beginBoundaryChanged = !start.equals(interval.startedAt()); + boolean endBoundaryChanged = !end.equals(interval.endedAt()); + return new TachographEsperPotentialHomeOvernightStayIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + durationSeconds, + interval.cardAbsentDurationSeconds(), + interval.cardAbsentCoveragePercent(), + interval.previousDrivingSourceIntervalId(), + interval.nextDrivingSourceIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey(), + beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(), + endBoundaryChanged ? null : interval.endBoundaryOdometerKm(), + beginBoundaryChanged ? null : interval.beginGeoEventId(), + beginBoundaryChanged ? null : interval.beginGeoEventDomain(), + beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), + beginBoundaryChanged ? null : interval.beginLatitude(), + beginBoundaryChanged ? null : interval.beginLongitude(), + beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(), + beginBoundaryChanged ? null : interval.beginGeoOdometerKm(), + endBoundaryChanged ? null : interval.endGeoEventId(), + endBoundaryChanged ? null : interval.endGeoEventDomain(), + endBoundaryChanged ? null : interval.endGeoOccurredAt(), + endBoundaryChanged ? null : interval.endLatitude(), + endBoundaryChanged ? null : interval.endLongitude(), + endBoundaryChanged ? null : interval.endGeoDistanceSeconds(), + endBoundaryChanged ? null : interval.endGeoOdometerKm(), + beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(), + beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(), + beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()), + endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm()) + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::startedAt) + .thenComparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperPotentialInVehicleOvernightStayIntervalEvents( + List intervals, + List rawVuCardAbsentIntervals, + List rawVehicleUsageIntervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + long durationSeconds = Duration.between(start, end).getSeconds(); + boolean beginBoundaryChanged = !start.equals(interval.startedAt()); + boolean endBoundaryChanged = !end.equals(interval.endedAt()); + return new TachographEsperPotentialInVehicleOvernightStayIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + durationSeconds, + interval.cardAbsentDurationSeconds(), + interval.cardAbsentCoveragePercent(), + interval.previousDrivingSourceIntervalId(), + interval.nextDrivingSourceIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey(), + beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(), + endBoundaryChanged ? null : interval.endBoundaryOdometerKm(), + beginBoundaryChanged ? null : interval.beginGeoEventId(), + beginBoundaryChanged ? null : interval.beginGeoEventDomain(), + beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), + beginBoundaryChanged ? null : interval.beginLatitude(), + beginBoundaryChanged ? null : interval.beginLongitude(), + beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(), + beginBoundaryChanged ? null : interval.beginGeoOdometerKm(), + endBoundaryChanged ? null : interval.endGeoEventId(), + endBoundaryChanged ? null : interval.endGeoEventDomain(), + endBoundaryChanged ? null : interval.endGeoOccurredAt(), + endBoundaryChanged ? null : interval.endLatitude(), + endBoundaryChanged ? null : interval.endLongitude(), + endBoundaryChanged ? null : interval.endGeoDistanceSeconds(), + endBoundaryChanged ? null : interval.endGeoOdometerKm(), + beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(), + beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(), + beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()), + endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm()) + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt) + .thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperPotentialInVehicleTripIntervalEvents( + List intervals, + List potentialInVehicleOvernightStayIntervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + if (intervals == null || intervals.isEmpty() + || potentialInVehicleOvernightStayIntervals == null || potentialInVehicleOvernightStayIntervals.isEmpty()) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + List containedIntervals = + potentialInVehicleOvernightStayIntervals.stream() + .filter(candidate -> tripContainsPotentialInterval( + interval.driverKey(), + interval.registrationKey(), + interval.vehicleKey(), + start, + end, + candidate + )) + .sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt) + .thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt)) + .toList(); + if (containedIntervals.isEmpty()) { + return null; + } + TachographEsperPotentialInVehicleOvernightStayIntervalEvent first = containedIntervals.get(0); + TachographEsperPotentialInVehicleOvernightStayIntervalEvent last = + containedIntervals.get(containedIntervals.size() - 1); + return new TachographEsperPotentialInVehicleTripIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.registrationKey(), + interval.vehicleKey(), + containedIntervals.size(), + containedIntervals.stream() + .mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::durationSeconds) + .sum(), + containedIntervals.stream() + .mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::cardAbsentDurationSeconds) + .sum(), + first.startedAt(), + last.endedAt(), + first.previousDrivingSourceIntervalId(), + last.nextDrivingSourceIntervalId(), + containedIntervals + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperPotentialInVehicleTripIntervalEvent::startedAt) + .thenComparing(TachographEsperPotentialInVehicleTripIntervalEvent::endedAt)) + .toList(); + } + + private boolean tripContainsPotentialInterval( + String driverKey, + String registrationKey, + String vehicleKey, + OffsetDateTime tripStartedAt, + OffsetDateTime tripEndedAt, + TachographEsperPotentialInVehicleOvernightStayIntervalEvent candidate + ) { + if (!Objects.equals(driverKey, candidate.driverKey())) { + return false; + } + if (!Objects.equals(registrationKey, candidate.previousRegistrationKey())) { + return false; + } + if (vehicleKey != null && candidate.previousVehicleKey() != null + && !Objects.equals(vehicleKey, candidate.previousVehicleKey())) { + return false; + } + return !candidate.startedAt().isBefore(tripStartedAt) + && !candidate.endedAt().isAfter(tripEndedAt); + } + + + private List esperProjectionNotes() { + return List.of( + "This endpoint returns Esper-backed per-driver interval projections from the in-memory tachograph file-session model.", + "Driving intervals are a filtered projection of activity intervals where activityType = DRIVE.", + "Driving interruption intervals are gaps between consecutive driving intervals longer than the configured significant-driving threshold.", + "Driving interruption vehicle-change intervals are daily/weekly rest candidates where previousRegistrationKey differs from nextRegistrationKey.", + "Daily/weekly rest candidate intervals are driving interruption intervals longer than the configured minimum rest-period threshold.", + "Daily/weekly rest candidate coverage intervals enrich each rest candidate with card-present and card-absent coverage metrics computed from vehicle-usage and VU card-absent overlap.", + "Daily/weekly rest candidate coverage intervals also attach begin/end geo evidence from nearby support events for the same driver and boundary-side vehicle identity.", + "Boundary geo evidence prefers the nearest matching POSITION event, then PLACE, BORDER_CROSSING, and LOAD_UNLOAD within the configured lookback/lookahead windows.", + "If both begin and end geo evidence carry odometer values, geoEvidenceMovementCategory classifies the interval as STATIONARY, MINOR, MOVED, or UNKNOWN.", + "Unclassified daily/weekly rest candidate coverage intervals are the rest candidates that are neither potential home overnight stays nor potential in-vehicle overnight stays.", + "Potential home overnight stay intervals are vehicle-change daily/weekly rest candidate coverage intervals where VU card-absent overlap covers at least 95% of the candidate interval.", + "Potential in-vehicle overnight stay intervals are no-change daily/weekly rest candidate coverage intervals where card-present overlap covers the candidate rest period.", + "Potential in-vehicle trip intervals span from the end of the coverage interval before a same-vehicle in-vehicle-overnight sequence to the start of the first coverage interval after that sequence.", + "VU card-absent intervals are gaps between consecutive normalized vehicle-usage intervals for the same driver.", + "occurredFrom and occurredTo clip the returned interval projections to the requested UTC time window.", + "Vehicle-usage intervals clear clipped odometer endpoints because boundary odometer values cannot be recomputed safely from the source interval." + ); + } + + private OffsetDateTime utc(OffsetDateTime value) { + return value == null ? null : value.withOffsetSameInstant(java.time.ZoneOffset.UTC); + } + + private TachographEsperGeoEvidenceEvent geoEvidenceEvent( + String eventId, + String eventDomain, + OffsetDateTime occurredAt, + Double latitude, + Double longitude, + Long distanceSeconds, + Long odometerKm + ) { + if (eventId == null + && eventDomain == null + && occurredAt == null + && latitude == null + && longitude == null + && distanceSeconds == null + && odometerKm == null) { + return null; + } + return new TachographEsperGeoEvidenceEvent( + eventId, + eventDomain, + occurredAt, + latitude, + longitude, + distanceSeconds, + odometerKm + ); + } + + private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isAfter(right) ? left : right; + } + + private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isBefore(right) ? left : right; + } +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingInput.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingInput.java new file mode 100644 index 0000000..947d6d6 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingInput.java @@ -0,0 +1,88 @@ +package at.procon.eventhub.tachographfilesession.service; + +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public record TachographEsperProcessingInput( + UUID sessionId, + String driverKey, + TachographFileSession session, + DriverExtractionSession driverSession, + ResolvedDriverTimeline timeline, + List eventInputEvents, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo, + int significantDrivingMinutes, + int minimumRestPeriodMinutes, + boolean forceEventInput, + List notes +) { + public TachographEsperProcessingInput { + eventInputEvents = eventInputEvents == null ? List.of() : List.copyOf(eventInputEvents); + significantDrivingMinutes = Math.max(1, significantDrivingMinutes); + minimumRestPeriodMinutes = Math.max(1, minimumRestPeriodMinutes); + notes = notes == null ? List.of() : List.copyOf(notes); + } + + public static TachographEsperProcessingInput fromFileSession( + TachographFileSession session, + DriverExtractionSession driverSession, + ResolvedDriverTimeline timeline, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo, + int significantDrivingMinutes, + int minimumRestPeriodMinutes, + List notes + ) { + return new TachographEsperProcessingInput( + session == null ? null : session.sessionId(), + driverSession == null ? null : driverSession.driverKey(), + session, + driverSession, + timeline, + List.of(), + requestedFrom, + requestedTo, + significantDrivingMinutes, + minimumRestPeriodMinutes, + false, + notes + ); + } + + public static TachographEsperProcessingInput fromEvents( + UUID sessionId, + String driverKey, + ResolvedDriverTimeline timeline, + List eventInputEvents, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo, + int significantDrivingMinutes, + int minimumRestPeriodMinutes, + List notes + ) { + return new TachographEsperProcessingInput( + sessionId, + driverKey, + null, + null, + timeline, + eventInputEvents, + requestedFrom, + requestedTo, + significantDrivingMinutes, + minimumRestPeriodMinutes, + true, + notes + ); + } + + public boolean hasEventInputEvents() { + return !eventInputEvents.isEmpty(); + } +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java index 08fd605..7e44494 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java @@ -33,6 +33,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service @@ -42,6 +43,7 @@ public class TachographFileSessionProcessingService { private final DriverTimelineBuilder driverTimelineBuilder; private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder; private final EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder; + private final TachographEsperProcessingCore esperProcessingCore; private final EventHubProperties properties; public TachographFileSessionProcessingService( @@ -50,12 +52,32 @@ public class TachographFileSessionProcessingService { DriverTimelineReusableProjectionBuilder reusableProjectionBuilder, EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder, EventHubProperties properties + ) { + this( + repository, + driverTimelineBuilder, + reusableProjectionBuilder, + eventBackedDriverTimelineBuilder, + properties, + new TachographEsperProcessingCore(driverTimelineBuilder, reusableProjectionBuilder, properties) + ); + } + + @Autowired + public TachographFileSessionProcessingService( + TachographFileSessionRepository repository, + DriverTimelineBuilder driverTimelineBuilder, + DriverTimelineReusableProjectionBuilder reusableProjectionBuilder, + EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder, + EventHubProperties properties, + TachographEsperProcessingCore esperProcessingCore ) { this.repository = repository; this.driverTimelineBuilder = driverTimelineBuilder; this.reusableProjectionBuilder = reusableProjectionBuilder; this.eventBackedDriverTimelineBuilder = eventBackedDriverTimelineBuilder; this.properties = properties; + this.esperProcessingCore = esperProcessingCore; } public TachographOperatingPeriodsProcessingResultDto evaluateOperatingPeriods( @@ -156,160 +178,26 @@ public class TachographFileSessionProcessingService { } ResolvedDriverTimeline timeline = resolveTimeline(session, driver); - OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null ? timeline.loadedFrom() : utc(effectiveRequest.occurredFrom()); - OffsetDateTime requestedTo = effectiveRequest.occurredTo() == null ? timeline.loadedTo() : utc(effectiveRequest.occurredTo()); + OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null + ? timeline.loadedFrom() + : utc(effectiveRequest.occurredFrom()); + OffsetDateTime requestedTo = effectiveRequest.occurredTo() == null + ? timeline.loadedTo() + : utc(effectiveRequest.occurredTo()); if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) { throw new IllegalArgumentException("occurredTo must not be before occurredFrom."); } - int significantDrivingMinutes = resolveEsperSignificantDrivingMinutes(effectiveRequest); - int minimumRestPeriodMinutes = resolveMinimumRestPeriodMinutes(effectiveRequest); - List activityIntervals = clipEsperActivityIntervalEvents( - driverTimelineBuilder.buildEsperActivityIntervalEvents(sessionId, driverKey, timeline), - requestedFrom, - requestedTo - ); - List drivingIntervals = clipEsperActivityIntervalEvents( - driverTimelineBuilder.buildEsperDrivingIntervalEvents(sessionId, driverKey, timeline), - requestedFrom, - requestedTo - ); - TachographEsperDrivingDerivedProjectionBundle derivedProjectionBundle = - properties.getTachographFileSession().getProcessing().getDrivingDerivedProjectionInputMode() - == EventHubProperties.DrivingDerivedProjectionInputMode.EVENTS - ? reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle( - session, - driver, - significantDrivingMinutes, - minimumRestPeriodMinutes - ) - : reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle( - sessionId, - driverKey, - timeline, - significantDrivingMinutes, - minimumRestPeriodMinutes - ); - List rawDrivingInterruptionIntervals = - derivedProjectionBundle.drivingInterruptionIntervals(); - List drivingInterruptionIntervals = - clipEsperDrivingInterruptionIntervalEvents( - rawDrivingInterruptionIntervals, - requestedFrom, - requestedTo - ); - List rawDailyWeeklyRestCandidateIntervals = - derivedProjectionBundle.dailyWeeklyRestCandidateIntervals(); - List dailyWeeklyRestCandidateIntervals = - clipEsperDrivingInterruptionIntervalEvents( - rawDailyWeeklyRestCandidateIntervals, - requestedFrom, - requestedTo - ); - List rawDrivingInterruptionVehicleChangeIntervals = - derivedProjectionBundle.drivingInterruptionVehicleChangeIntervals(); - List drivingInterruptionVehicleChangeIntervals = - clipEsperDrivingInterruptionIntervalEvents( - rawDrivingInterruptionVehicleChangeIntervals, - requestedFrom, - requestedTo - ); - List rawVehicleUsageIntervals = - driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline); - List rawVuCardAbsentIntervals = - derivedProjectionBundle.vuCardAbsentIntervals(); - List potentialHomeOvernightStayIntervals = - clipEsperPotentialHomeOvernightStayIntervalEvents( - derivedProjectionBundle.potentialHomeOvernightStayIntervals(), - rawVuCardAbsentIntervals, - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List dailyWeeklyRestCandidateCoverageIntervals = - clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( - derivedProjectionBundle.dailyWeeklyRestCandidateCoverageIntervals(), - rawVuCardAbsentIntervals, - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List unclassifiedDailyWeeklyRestCandidateCoverageIntervals = - clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( - derivedProjectionBundle.unclassifiedDailyWeeklyRestCandidateCoverageIntervals(), - rawVuCardAbsentIntervals, - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List potentialInVehicleOvernightStayIntervals = - clipEsperPotentialInVehicleOvernightStayIntervalEvents( - derivedProjectionBundle.potentialInVehicleOvernightStayIntervals(), - rawVuCardAbsentIntervals, - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List potentialInVehicleTripIntervals = - clipEsperPotentialInVehicleTripIntervalEvents( - derivedProjectionBundle.potentialInVehicleTripIntervals(), - potentialInVehicleOvernightStayIntervals, - requestedFrom, - requestedTo - ); - List vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents( - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents( - rawVuCardAbsentIntervals, - requestedFrom, - requestedTo - ); - List supportGeoEvents = clipEsperSupportGeoEvents( - timeline.supportEvents(), - driverKey, - requestedFrom, - requestedTo - ); - - return new TachographEsperDriverProcessingResultDto( - sessionId, - driverKey, - timeline.sourceKind(), - timeline.loadedFrom(), - timeline.loadedTo(), + return esperProcessingCore.process(TachographEsperProcessingInput.fromFileSession( + session, + driver, + timeline, requestedFrom, requestedTo, - activityIntervals.size(), - drivingIntervals.size(), - drivingInterruptionIntervals.size(), - drivingInterruptionVehicleChangeIntervals.size(), - dailyWeeklyRestCandidateIntervals.size(), - dailyWeeklyRestCandidateCoverageIntervals.size(), - unclassifiedDailyWeeklyRestCandidateCoverageIntervals.size(), - potentialHomeOvernightStayIntervals.size(), - potentialInVehicleOvernightStayIntervals.size(), - potentialInVehicleTripIntervals.size(), - vehicleUsageIntervals.size(), - vuCardAbsentIntervals.size(), - supportGeoEvents.size(), - activityIntervals, - drivingIntervals, - drivingInterruptionIntervals, - drivingInterruptionVehicleChangeIntervals, - dailyWeeklyRestCandidateIntervals, - dailyWeeklyRestCandidateCoverageIntervals, - unclassifiedDailyWeeklyRestCandidateCoverageIntervals, - potentialHomeOvernightStayIntervals, - potentialInVehicleOvernightStayIntervals, - potentialInVehicleTripIntervals, - vehicleUsageIntervals, - vuCardAbsentIntervals, - supportGeoEvents, - esperProjectionNotes() - ); + resolveEsperSignificantDrivingMinutes(effectiveRequest), + resolveMinimumRestPeriodMinutes(effectiveRequest), + List.of() + )); } private ResolvedDriverTimeline resolveTimeline( 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 2d777f7..e93c671 100644 --- a/src/test/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingControllerTest.java +++ b/src/test/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingControllerTest.java @@ -2,6 +2,7 @@ package at.procon.eventhub.processing.api; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -14,13 +15,21 @@ 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.UnifiedRuntimeDerivedProjectionResultDto; +import at.procon.eventhub.processing.eventprocessing.RuntimeEventProcessingService; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto; +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.model.UnifiedDiscoveredVehicleRef; import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle; import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import at.procon.eventhub.processing.service.UnifiedRuntimeDerivedProjectionService; import at.procon.eventhub.processing.service.UnifiedRuntimeDriverTimelineService; import at.procon.eventhub.processing.service.UnifiedRuntimeEventAssemblyService; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval; import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval; @@ -48,7 +57,8 @@ class UnifiedRuntimeProcessingControllerTest { void loadsDriverEventsViaRuntimeApi() throws Exception { UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class); UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class); - MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService)) + UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService, derivedProjectionService)) .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) .build(); @@ -98,7 +108,8 @@ class UnifiedRuntimeProcessingControllerTest { void loadsDriverTimelineViaRuntimeApi() throws Exception { UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class); UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class); - MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService)) + UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService, derivedProjectionService)) .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) .build(); @@ -162,11 +173,286 @@ class UnifiedRuntimeProcessingControllerTest { .andExpect(jsonPath("$.vehicleUsageIntervals[0].intervalId").value("CVU-1")); } + + @Test + void loadsDriverDerivedProjectionsViaRuntimeApi() 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); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService, derivedProjectionService)) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) + .build(); + + UUID sessionId = UUID.randomUUID(); + UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographFileSession( + sessionId, + "12:123", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + true, + 0 + ); + TachographEsperDriverProcessingResultDto projection = new TachographEsperDriverProcessingResultDto( + sessionId, + "12:123", + "UNIFIED_EVENT_STREAM", + 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"), + 0, + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 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("runtime derived") + ); + when(derivedProjectionService.loadDriverDerivedProjections(any())) + .thenReturn(new UnifiedRuntimeDerivedProjectionResultDto( + request, + 2, + 1, + 1, + 3, + List.of(new UnifiedDiscoveredVehicleRef("VEH-1", "VIN-1", "12", "REG-1")), + projection, + List.of("runtime derived") + )); + + mockMvc.perform(post("/api/eventhub/runtime-processing/driver-derived-projections") + .contentType("application/json") + .content(""" + { + "sessionId": "%s", + "sourceFamilies": ["TACHOGRAPH_FILE_SESSION"], + "driverKey": "12:123", + "occurredFrom": "2026-05-01T08:00:00Z", + "occurredTo": "2026-05-01T10:00:00Z", + "expandVehicleEvents": true, + "significantDrivingMinutes": 3, + "minimumRestPeriodMinutes": 720 + } + """.formatted(sessionId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.mergedEventCount").value(3)) + .andExpect(jsonPath("$.projection.driverKey").value("12:123")) + .andExpect(jsonPath("$.projection.drivingInterruptionIntervalCount").value(1)) + .andExpect(jsonPath("$.projection.dailyWeeklyRestCandidateIntervalCount").value(1)); + } + + + @Test + void listsRuntimeEventProcessingProfilesViaRuntimeApi() 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); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController( + eventAssemblyService, + timelineService, + derivedProjectionService, + null, + runtimeEventProcessingService + )) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) + .build(); + + when(runtimeEventProcessingService.listProfiles()) + .thenReturn(List.of(new RuntimeEventProcessingProfileDescriptorDto( + "tachograph-driver-esper-v1", + "Tachograph Driver Esper Processing", + "Runs tachograph driver Esper processing over runtime event scopes.", + RuntimeEventPartitioningStrategy.DRIVER, + List.of(RuntimeEventPartitioningStrategy.DRIVER), + Set.of(), + Set.of("significantDrivingMinutes", "minimumRestPeriodMinutes") + ))); + + mockMvc.perform(get("/api/eventhub/runtime-processing/event-processing/profiles")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].profileKey").value("tachograph-driver-esper-v1")) + .andExpect(jsonPath("$[0].displayName").value("Tachograph Driver Esper Processing")) + .andExpect(jsonPath("$[0].defaultPartitioningStrategy").value("DRIVER")) + .andExpect(jsonPath("$[0].supportedPartitioningStrategies[0]").value("DRIVER")) + .andExpect(jsonPath("$[0].optionalParameters[0]").exists()); + } + + @Test + void runsGenericEventProcessingProfileViaRuntimeApi() 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); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController( + eventAssemblyService, + timelineService, + derivedProjectionService, + null, + runtimeEventProcessingService + )) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) + .build(); + + UUID sessionId = UUID.randomUUID(); + UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographFileSession( + sessionId, + "12:123", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + true, + 0 + ); + when(runtimeEventProcessingService.process(any())) + .thenReturn(new RuntimeEventProcessingResultDto( + "tachograph-driver-esper-v1", + RuntimeEventPartitioningStrategy.DRIVER, + request, + 3, + 1, + 1, + List.of(new UnifiedDiscoveredVehicleRef("VEH-1", "VIN-1", "12", "REG-1")), + Map.of(), + List.of("generic profile"), + List.of() + )); + + mockMvc.perform(post("/api/eventhub/runtime-processing/event-processing") + .contentType("application/json") + .content(""" + { + "profileKey": "tachograph-driver-esper-v1", + "scope": { + "sessionId": "%s", + "sourceFamilies": ["TACHOGRAPH_FILE_SESSION"], + "driverKey": "12:123", + "occurredFrom": "2026-05-01T08:00:00Z", + "occurredTo": "2026-05-01T10:00:00Z" + }, + "partitioning": { + "strategy": "DRIVER", + "includeAllPartitions": false + }, + "parameters": { + "significantDrivingMinutes": 3, + "minimumRestPeriodMinutes": 720 + } + } + """.formatted(sessionId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.profileKey").value("tachograph-driver-esper-v1")) + .andExpect(jsonPath("$.partitioningStrategy").value("DRIVER")) + .andExpect(jsonPath("$.inputEventCount").value(3)) + .andExpect(jsonPath("$.discoveredVehicles[0].vin").value("VIN-1")); + } + + + @Test + void compatibilityTachographEndpointDelegatesThroughGenericProfileRuntimeApi() 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); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController( + eventAssemblyService, + timelineService, + derivedProjectionService, + null, + runtimeEventProcessingService + )) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) + .build(); + + UUID sessionId = UUID.randomUUID(); + UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographFileSession( + sessionId, + "12:123", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + true, + 0 + ); + UnifiedRuntimeDerivedProjectionResultDto driverResult = new UnifiedRuntimeDerivedProjectionResultDto( + request, + 2, + 1, + 3, + 5, + List.of(new UnifiedDiscoveredVehicleRef("VEH-1", "VIN-1", "12", "REG-1")), + null, + List.of("processed through generic profile") + ); + when(runtimeEventProcessingService.process(any())) + .thenReturn(new RuntimeEventProcessingResultDto( + "tachograph-driver-esper-v1", + RuntimeEventPartitioningStrategy.DRIVER, + request, + 5, + 1, + 1, + List.of(new UnifiedDiscoveredVehicleRef("VEH-1", "VIN-1", "12", "REG-1")), + Map.of("12:123", new RuntimeEventProcessingPartitionResultDto( + "DRIVER", + "12:123", + "UnifiedRuntimeDerivedProjectionResultDto", + driverResult, + Map.of("mergedEventCount", 5) + )), + List.of("generic adapter"), + List.of() + )); + + mockMvc.perform(post("/api/eventhub/runtime-processing/tachograph/esper-processing") + .contentType("application/json") + .content(""" + { + "sessionId": "%s", + "sourceFamilies": ["TACHOGRAPH_FILE_SESSION"], + "driverKey": "12:123", + "occurredFrom": "2026-05-01T08:00:00Z", + "occurredTo": "2026-05-01T10:00:00Z", + "expandVehicleEvents": true, + "significantDrivingMinutes": 3, + "minimumRestPeriodMinutes": 720 + } + """.formatted(sessionId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.inputEventCount").value(5)) + .andExpect(jsonPath("$.selectedDriverCount").value(1)) + .andExpect(jsonPath("$.driverResults['12:123'].mergedEventCount").value(5)) + .andExpect(jsonPath("$.notes[0]").value("generic adapter")); + } + @Test void returnsBadRequestForInvalidRuntimeRequest() throws Exception { UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class); UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class); - MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService)) + UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService, derivedProjectionService)) .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) .build(); diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingServiceTest.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingServiceTest.java new file mode 100644 index 0000000..7cdda71 --- /dev/null +++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingServiceTest.java @@ -0,0 +1,95 @@ +package at.procon.eventhub.processing.eventprocessing; + +import static org.assertj.core.api.Assertions.assertThat; + +import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventPartitioningApiRequest; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto; +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import at.procon.eventhub.processing.eventprocessing.profile.RuntimeEventProcessingProfile; +import at.procon.eventhub.processing.eventprocessing.profile.RuntimeEventProcessingProfileRegistry; +import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class RuntimeEventProcessingServiceTest { + + @Test + void processesThroughSelectedProfileAndListsProfiles() { + RuntimeEventProcessingService service = new RuntimeEventProcessingService( + new RuntimeEventProcessingProfileRegistry(List.of(new EchoProfile())) + ); + UUID sessionId = UUID.randomUUID(); + RuntimeEventProcessingApiRequest request = new RuntimeEventProcessingApiRequest( + "echo-profile-v1", + new UnifiedRuntimeProcessingApiRequest( + sessionId, + List.of(), + null, + null, + Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), + null, + "12:DRIVER-1", + Set.of(), + false, + Set.of(), + false, + null, + null, + null, + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-01T01:00:00Z"), + true, + 0, + null, + null + ), + new RuntimeEventPartitioningApiRequest(RuntimeEventPartitioningStrategy.DRIVER, null, false, null, false, null, false, null, null), + Map.of() + ); + + RuntimeEventProcessingResultDto result = service.process(request); + + assertThat(result.profileKey()).isEqualTo("echo-profile-v1"); + assertThat(result.partitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER); + var profiles = service.listProfiles(); + assertThat(profiles).hasSize(1); + assertThat(profiles.get(0).profileKey()).isEqualTo("echo-profile-v1"); + } + + private static final class EchoProfile implements RuntimeEventProcessingProfile { + + @Override + public String profileKey() { + return "echo-profile-v1"; + } + + @Override + public RuntimeEventPartitioningStrategy defaultPartitioningStrategy() { + return RuntimeEventPartitioningStrategy.DRIVER; + } + + @Override + public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) { + UnifiedRuntimeProcessingRequest runtimeRequest = request.scope().toRuntimeRequest(); + return new RuntimeEventProcessingResultDto( + profileKey(), + defaultPartitioningStrategy(), + runtimeRequest, + 0, + 0, + 0, + List.of(), + Map.of(), + List.of("echo"), + List.of() + ); + } + } +} diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfileRegistryTest.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfileRegistryTest.java new file mode 100644 index 0000000..1e8c466 --- /dev/null +++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/profile/RuntimeEventProcessingProfileRegistryTest.java @@ -0,0 +1,68 @@ +package at.procon.eventhub.processing.eventprocessing.profile; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto; +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RuntimeEventProcessingProfileRegistryTest { + + @Test + void exposesProfileDescriptorsForDiscovery() { + RuntimeEventProcessingProfileRegistry registry = new RuntimeEventProcessingProfileRegistry(List.of(new TestProfile())); + + var descriptors = registry.profileDescriptors(); + + assertThat(descriptors).hasSize(1); + assertThat(descriptors.get(0).profileKey()).isEqualTo("test-profile-v1"); + assertThat(descriptors.get(0).displayName()).isEqualTo("Test Profile"); + assertThat(descriptors.get(0).defaultPartitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER); + assertThat(descriptors.get(0).supportedPartitioningStrategies()).containsExactly(RuntimeEventPartitioningStrategy.DRIVER); + assertThat(descriptors.get(0).optionalParameters()).containsExactly("thresholdMinutes"); + } + + @Test + void rejectsDuplicateProfileKeys() { + assertThatThrownBy(() -> new RuntimeEventProcessingProfileRegistry(List.of(new TestProfile(), new TestProfile()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Duplicate runtime event processing profileKey"); + } + + private static final class TestProfile implements RuntimeEventProcessingProfile { + + @Override + public String profileKey() { + return "test-profile-v1"; + } + + @Override + public String displayName() { + return "Test Profile"; + } + + @Override + public String description() { + return "Test profile descriptor."; + } + + @Override + public RuntimeEventPartitioningStrategy defaultPartitioningStrategy() { + return RuntimeEventPartitioningStrategy.DRIVER; + } + + @Override + public Set optionalParameters() { + return Set.of("thresholdMinutes"); + } + + @Override + public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) { + throw new UnsupportedOperationException("Not needed by this test"); + } + } +} 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 new file mode 100644 index 0000000..51c3bc9 --- /dev/null +++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfileTest.java @@ -0,0 +1,149 @@ +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.Mockito.verify; +import static org.mockito.Mockito.when; + +import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto; +import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.dto.UnifiedRuntimeTachographEsperScopeResultDto; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventPartitioningApiRequest; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest; +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import at.procon.eventhub.processing.service.UnifiedRuntimeTachographEsperScopeProcessingService; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class TachographDriverEsperRuntimeEventProcessingProfileTest { + + @Test + void exposesDiscoveryMetadata() { + TachographDriverEsperRuntimeEventProcessingProfile profile = new TachographDriverEsperRuntimeEventProcessingProfile( + org.mockito.Mockito.mock(UnifiedRuntimeTachographEsperScopeProcessingService.class) + ); + + assertThat(profile.profileKey()).isEqualTo("tachograph-driver-esper-v1"); + 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"); + } + + @Test + void delegatesToTachographScopeServiceAndMapsPartitionResults() { + UnifiedRuntimeTachographEsperScopeProcessingService scopeService = org.mockito.Mockito.mock(UnifiedRuntimeTachographEsperScopeProcessingService.class); + TachographDriverEsperRuntimeEventProcessingProfile profile = new TachographDriverEsperRuntimeEventProcessingProfile(scopeService); + + UUID sessionId = UUID.randomUUID(); + UnifiedRuntimeProcessingApiRequest scope = new UnifiedRuntimeProcessingApiRequest( + sessionId, + List.of(), + null, + null, + Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), + null, + null, + Set.of(), + false, + Set.of(), + false, + null, + null, + null, + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-31T23:59:59Z"), + true, + 15, + null, + null + ); + RuntimeEventProcessingApiRequest request = new RuntimeEventProcessingApiRequest( + TachographDriverEsperRuntimeEventProcessingProfile.PROFILE_KEY, + scope, + new RuntimeEventPartitioningApiRequest( + RuntimeEventPartitioningStrategy.DRIVER, + Set.of("12:DRIVER-1"), + false, + Set.of(), + false, + null, + null, + null, + null + ), + Map.of( + "significantDrivingMinutes", 5, + "minimumRestPeriodMinutes", "600", + "vehicleEvidencePaddingMinutes", 20, + "attachVehicleOnlyEvents", true + ) + ); + + UnifiedRuntimeProcessingRequest processedRequest = new UnifiedRuntimeProcessingRequest( + sessionId, + List.of(sessionId), + null, + null, + Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), + null, + "12:DRIVER-1", + Set.of("12:DRIVER-1"), + false, + Set.of(), + false, + null, + null, + null, + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-31T23:59:59Z"), + true, + 15 + ); + UnifiedRuntimeDerivedProjectionResultDto driverResult = new UnifiedRuntimeDerivedProjectionResultDto( + processedRequest, + 2, + 1, + 3, + 5, + List.of(), + null, + List.of("driver processed") + ); + when(scopeService.processScope(any())) + .thenReturn(new UnifiedRuntimeTachographEsperScopeResultDto( + processedRequest, + 5, + 1, + 1, + List.of(), + Map.of("12:DRIVER-1", driverResult), + List.of("scope processed"), + List.of() + )); + + var result = profile.process(request); + + assertThat(result.profileKey()).isEqualTo(TachographDriverEsperRuntimeEventProcessingProfile.PROFILE_KEY); + assertThat(result.partitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER); + assertThat(result.partitionResults()).containsOnlyKeys("12:DRIVER-1"); + assertThat(result.partitionResults().get("12:DRIVER-1").partitionType()).isEqualTo("DRIVER"); + assertThat(result.partitionResults().get("12:DRIVER-1").result()).isSameAs(driverResult); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UnifiedRuntimeProcessingApiRequest.class); + verify(scopeService).processScope(captor.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(); + } +} diff --git a/src/test/java/at/procon/eventhub/processing/model/UnifiedRuntimeProcessingRequestTest.java b/src/test/java/at/procon/eventhub/processing/model/UnifiedRuntimeProcessingRequestTest.java index ec4dd27..68acd4e 100644 --- a/src/test/java/at/procon/eventhub/processing/model/UnifiedRuntimeProcessingRequestTest.java +++ b/src/test/java/at/procon/eventhub/processing/model/UnifiedRuntimeProcessingRequestTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.OffsetDateTime; +import java.util.List; import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -89,14 +90,141 @@ class UnifiedRuntimeProcessingRequestTest { assertThat(request.eventBackend()).isEqualTo(UnifiedRuntimeEventBackend.SOURCE_DB); } + + + @Test + void canBuildMultiFileSessionRuntimeRequest() { + UUID firstSessionId = UUID.randomUUID(); + UUID secondSessionId = UUID.randomUUID(); + UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographFileSessions( + List.of(firstSessionId, secondSessionId), + "12:123", + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-03T00:00:00Z"), + true, + 10 + ); + + assertThat(request.sessionId()).isEqualTo(firstSessionId); + assertThat(request.sessionIds()).containsExactly(firstSessionId, secondSessionId); + assertThat(request.compositeSessionId()).isNull(); + } + + @Test + void canBuildCompositeFileSessionRuntimeRequest() { + UUID compositeSessionId = UUID.randomUUID(); + UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographCompositeSession( + compositeSessionId, + "12:123", + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-03T00:00:00Z"), + true, + 10 + ); + + assertThat(request.sessionId()).isNull(); + assertThat(request.sessionIds()).isEmpty(); + assertThat(request.compositeSessionId()).isEqualTo(compositeSessionId); + } + + @Test + void canBuildMultiDriverFileSessionRuntimeScopeRequest() { + UUID sessionId = UUID.randomUUID(); + UnifiedRuntimeProcessingRequest request = new UnifiedRuntimeProcessingRequest( + sessionId, + List.of(), + null, + null, + Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), + UnifiedRuntimeEventBackend.SOURCE_DB, + null, + Set.of("12:123", "12:456"), + false, + Set.of(), + false, + null, + null, + null, + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-02T00:00:00Z"), + true, + 10 + ); + + assertThat(request.driverKey()).isNull(); + assertThat(request.driverKeys()).containsExactlyInAnyOrder("12:123", "12:456"); + } + + @Test + void canBuildIncludeAllDriversRuntimeScopeRequest() { + UUID sessionId = UUID.randomUUID(); + UnifiedRuntimeProcessingRequest request = new UnifiedRuntimeProcessingRequest( + sessionId, + List.of(), + null, + null, + Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), + UnifiedRuntimeEventBackend.SOURCE_DB, + null, + Set.of(), + true, + Set.of(), + false, + null, + null, + null, + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-02T00:00:00Z"), + true, + 10 + ); + + assertThat(request.includeAllDrivers()).isTrue(); + assertThat(request.driverKey()).isNull(); + } + + @Test + void rejectsAmbiguousExplicitAndCompositeSessionSelectors() { + UUID sessionId = UUID.randomUUID(); + UUID compositeSessionId = UUID.randomUUID(); + assertThatThrownBy(() -> new UnifiedRuntimeProcessingRequest( + sessionId, + List.of(), + compositeSessionId, + null, + Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), + UnifiedRuntimeEventBackend.SOURCE_DB, + "12:123", + Set.of(), + false, + Set.of(), + false, + null, + null, + null, + null, + null, + true, + 0 + )).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Use either compositeSessionId"); + } + + @Test void rejectsRequestWithoutDriverSelector() { assertThatThrownBy(() -> new UnifiedRuntimeProcessingRequest( + null, + List.of(), null, "default", Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB), UnifiedRuntimeEventBackend.SOURCE_DB, null, + Set.of(), + false, + Set.of(), + false, null, null, null, diff --git a/src/test/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentServiceTest.java b/src/test/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentServiceTest.java new file mode 100644 index 0000000..a45dfdc --- /dev/null +++ b/src/test/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentServiceTest.java @@ -0,0 +1,205 @@ +package at.procon.eventhub.processing.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import at.procon.eventhub.dto.DriverRefDto; +import at.procon.eventhub.dto.EventDomain; +import at.procon.eventhub.dto.EventHubEventDto; +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.eventprocessing.partition.RuntimeEventScopeClassifier; +import at.procon.eventhub.processing.model.RuntimeDriverVehicleEvidenceAttachmentResult; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class RuntimeDriverVehicleEvidenceAttachmentServiceTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final RuntimeDriverVehicleEvidenceAttachmentService service = new RuntimeDriverVehicleEvidenceAttachmentService( + new UnifiedEventTimelineReconstructor(), + new RuntimeEventScopeClassifier() + ); + + @Test + void attachesVehicleOnlyEvidenceInsideDriverVehicleUsageInterval() { + 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 vehicleEvidence = 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"); + EventHubEventDto wrongVehicle = vehicleOnlyEvent("pos-wrong", "VIN-2", "AT:W-2", "2026-05-01T12:00:00Z"); + + RuntimeDriverVehicleEvidenceAttachmentResult result = service.attachVehicleEvidence( + "DRIVER-1", + driverEvents, + List.of(driverEvents.get(0), driverEvents.get(1), vehicleEvidence, outside, wrongVehicle), + true, + 0 + ); + + assertThat(result.vehicleUsageIntervalCount()).isEqualTo(1); + assertThat(result.candidateVehicleEvidenceEventCount()).isEqualTo(3); + assertThat(result.attachedVehicleEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId) + .containsExactly("pos-inside"); + assertThat(result.ignoredVehicleEvidenceEventCount()).isEqualTo(2); + assertThat(result.mergedEvents()).extracting(EventHubEventDto::externalSourceEventId) + .containsExactly("card-1-in", "pos-inside", "card-1-out"); + } + + @Test + void usesConfiguredPaddingWhenAttachingVehicleEvidence() { + 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 justAfter = vehicleOnlyEvent("pos-after", "VIN-1", "AT:W-1", "2026-05-01T18:10:00Z"); + + RuntimeDriverVehicleEvidenceAttachmentResult result = service.attachVehicleEvidence( + "DRIVER-1", + driverEvents, + List.of(driverEvents.get(0), driverEvents.get(1), justAfter), + true, + 15 + ); + + assertThat(result.attachedVehicleEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId) + .containsExactly("pos-after"); + } + + @Test + void doesNotAttachVehicleEvidenceWhenDisabled() { + 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 vehicleEvidence = vehicleOnlyEvent("pos-inside", "VIN-1", "AT:W-1", "2026-05-01T12:00:00Z"); + + RuntimeDriverVehicleEvidenceAttachmentResult result = service.attachVehicleEvidence( + "DRIVER-1", + driverEvents, + List.of(driverEvents.get(0), driverEvents.get(1), vehicleEvidence), + false, + 0 + ); + + assertThat(result.attachedVehicleEvidenceEvents()).isEmpty(); + assertThat(result.mergedEvents()).extracting(EventHubEventDto::externalSourceEventId) + .containsExactly("card-1-in", "card-1-out"); + } + + @Test + void mergesMidnightVehicleUsageContinuationBeforeEvidenceAttachment() { + List driverEvents = List.of( + cardEvent("card-1-in", EventType.CARD_INSERTED, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T20:00:00Z"), + cardEvent("card-1-out", EventType.CARD_WITHDRAWN, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T23:59:59Z"), + cardEvent("card-2-in", EventType.CARD_INSERTED, "DRIVER-1", "interval-2", "VIN-1", "AT:W-1", "2026-05-02T00:00:00Z"), + cardEvent("card-2-out", EventType.CARD_WITHDRAWN, "DRIVER-1", "interval-2", "VIN-1", "AT:W-1", "2026-05-02T08:00:00Z") + ); + EventHubEventDto midnightEvidence = vehicleOnlyEvent("pos-midnight", "VIN-1", "AT:W-1", "2026-05-02T00:00:00Z"); + + RuntimeDriverVehicleEvidenceAttachmentResult result = service.attachVehicleEvidence( + "DRIVER-1", + driverEvents, + List.of(driverEvents.get(0), driverEvents.get(1), driverEvents.get(2), driverEvents.get(3), midnightEvidence), + true, + 0 + ); + + assertThat(result.vehicleUsageIntervalCount()).isEqualTo(1); + assertThat(result.attachedVehicleEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId) + .containsExactly("pos-midnight"); + } + + private EventHubEventDto cardEvent( + String externalId, + EventType eventType, + String driverKey, + String intervalId, + String vehicleKey, + String registrationKey, + String occurredAt + ) { + EventLifecycle lifecycle = eventType == EventType.CARD_INSERTED ? EventLifecycle.INSERT : EventLifecycle.WITHDRAW; + return new EventHubEventDto( + UUID.randomUUID(), + externalId, + new DriverRefDto(driverKey, null), + vehicleRef(vehicleKey, registrationKey), + OffsetDateTime.parse(occurredAt), + null, + OffsetDateTime.parse(occurredAt), + EventDomain.DRIVER_CARD, + eventType, + lifecycle, + null, + null, + null, + null, + raw(driverKey, intervalId, vehicleKey, registrationKey), + false, + null + ); + } + + private EventHubEventDto vehicleOnlyEvent( + String externalId, + String vehicleKey, + String registrationKey, + String occurredAt + ) { + return new EventHubEventDto( + UUID.randomUUID(), + externalId, + null, + vehicleRef(vehicleKey, registrationKey), + OffsetDateTime.parse(occurredAt), + null, + OffsetDateTime.parse(occurredAt), + EventDomain.POSITION, + EventType.POSITION_RECORDED, + EventLifecycle.SNAPSHOT, + null, + null, + null, + null, + raw(null, null, vehicleKey, registrationKey), + false, + null + ); + } + + private VehicleRefDto vehicleRef(String vehicleKey, String registrationKey) { + String[] registrationParts = registrationKey.split(":", 2); + return new VehicleRefDto( + "VIN:" + vehicleKey, + vehicleKey, + "VR:" + registrationKey, + new VehicleRegistrationRefDto(registrationParts[0], registrationParts[1]) + ); + } + + private JsonNode raw(String driverKey, String intervalId, String vehicleKey, String registrationKey) { + ObjectNode root = OBJECT_MAPPER.createObjectNode(); + ObjectNode raw = root.putObject("raw"); + if (driverKey != null) { + raw.put("driverKey", driverKey); + } + if (intervalId != null) { + raw.put("intervalId", intervalId); + raw.put("sourceRowId", intervalId); + } + raw.put("vehicleKey", vehicleKey); + raw.put("registrationKey", registrationKey); + raw.put("sourceKind", "TEST"); + return root; + } +} diff --git a/src/test/java/at/procon/eventhub/processing/service/TachographFileSessionRuntimeEventLoaderTest.java b/src/test/java/at/procon/eventhub/processing/service/TachographFileSessionRuntimeEventLoaderTest.java index 99b35f6..89294cd 100644 --- a/src/test/java/at/procon/eventhub/processing/service/TachographFileSessionRuntimeEventLoaderTest.java +++ b/src/test/java/at/procon/eventhub/processing/service/TachographFileSessionRuntimeEventLoaderTest.java @@ -5,7 +5,9 @@ import static org.assertj.core.api.Assertions.assertThat; import at.procon.eventhub.config.EventHubProperties; import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef; import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import at.procon.eventhub.service.EventAcquisitionRecordKeyService; import at.procon.eventhub.service.EventDetailsFactory; +import at.procon.eventhub.service.EventHubEventSorter; import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval; import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; @@ -19,6 +21,8 @@ import at.procon.eventhub.tachographfilesession.model.TachographFileSession; import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; import at.procon.eventhub.tachographfilesession.service.DriverKeyFactory; import at.procon.eventhub.tachographfilesession.service.DriverTimelineBuilder; +import at.procon.eventhub.tachographfilesession.model.TachographCompositeSession; +import at.procon.eventhub.tachographfilesession.service.InMemoryTachographCompositeSessionRepository; import at.procon.eventhub.tachographfilesession.service.InMemoryTachographFileSessionRepository; import at.procon.eventhub.tachographfilesession.service.IntervalBackedDriverTimelineEventBuilder; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository; @@ -39,6 +43,7 @@ class TachographFileSessionRuntimeEventLoaderTest { void loadsDriverAndVehicleEventsFromFileSessionRuntimePath() { EventHubProperties properties = new EventHubProperties(); TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); + InMemoryTachographCompositeSessionRepository compositeRepository = new InMemoryTachographCompositeSessionRepository(); IntervalBackedDriverTimelineEventBuilder eventBuilder = new IntervalBackedDriverTimelineEventBuilder( new DriverTimelineBuilder(), new DriverKeyFactory(), @@ -47,7 +52,10 @@ class TachographFileSessionRuntimeEventLoaderTest { ); TachographFileSessionRuntimeEventLoader loader = new TachographFileSessionRuntimeEventLoader( new UnifiedDriverEventSourceService(List.of(new TachographFileSessionUnifiedDriverEventSource(repository, eventBuilder))), - new UnifiedVehicleEventSourceService(List.of(new TachographFileSessionUnifiedVehicleEventSource(repository, eventBuilder))) + new UnifiedVehicleEventSourceService(List.of(new TachographFileSessionUnifiedVehicleEventSource(repository, eventBuilder))), + compositeRepository, + new EventAcquisitionRecordKeyService(), + new EventHubEventSorter() ); DriverExtractionSession driver = driver(); @@ -68,6 +76,56 @@ class TachographFileSessionRuntimeEventLoaderTest { assertThat(loader.loadVehicleEvents(request, new UnifiedDiscoveredVehicleRef("VIN-1", "VIN-1", "12", "REG-1"))).hasSize(5); } + + + @Test + void loadsDriverAndVehicleEventsFromCompositeSessionRuntimePath() { + EventHubProperties properties = new EventHubProperties(); + TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); + InMemoryTachographCompositeSessionRepository compositeRepository = new InMemoryTachographCompositeSessionRepository(); + IntervalBackedDriverTimelineEventBuilder eventBuilder = new IntervalBackedDriverTimelineEventBuilder( + new DriverTimelineBuilder(), + new DriverKeyFactory(), + new VehicleKeyFactory(), + new EventDetailsFactory(new ObjectMapper()) + ); + TachographFileSessionRuntimeEventLoader loader = new TachographFileSessionRuntimeEventLoader( + new UnifiedDriverEventSourceService(List.of(new TachographFileSessionUnifiedDriverEventSource(repository, eventBuilder))), + new UnifiedVehicleEventSourceService(List.of(new TachographFileSessionUnifiedVehicleEventSource(repository, eventBuilder))), + compositeRepository, + new EventAcquisitionRecordKeyService(), + new EventHubEventSorter() + ); + + DriverExtractionSession firstDriverSession = driver(); + DriverExtractionSession secondDriverSession = driverOnSecondDay(); + TachographFileSession firstSession = session(firstDriverSession); + TachographFileSession secondSession = session(secondDriverSession); + repository.save(firstSession); + repository.save(secondSession); + UUID compositeSessionId = UUID.randomUUID(); + compositeRepository.save(new TachographCompositeSession( + compositeSessionId, + "default", + "runtime-composite", + List.of(firstSession.sessionId(), secondSession.sessionId()), + Instant.now() + )); + + UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographCompositeSession( + compositeSessionId, + firstDriverSession.driverKey(), + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-03T00:00:00Z"), + true, + 0 + ); + + assertThat(loader.loadDriverEvents(request)).hasSize(10); + assertThat(loader.loadVehicleEvents(request, new UnifiedDiscoveredVehicleRef("VIN-1", "VIN-1", "12", "REG-1"))).hasSize(10); + } + + private DriverExtractionSession driver() { return new DriverExtractionSession( "12:123", @@ -125,6 +183,66 @@ class TachographFileSessionRuntimeEventLoaderTest { ); } + + + private DriverExtractionSession driverOnSecondDay() { + return new DriverExtractionSession( + "12:123", + new ExtractedDriver("12:123", "DRV:12:123", "Doe", "Jane", null, null, null, null, null), + new ExtractedDriverCard("CARD:12:123", "12", "123", "123", null, null, null, null), + List.of(new ExtractedVehicleRegistration("12:REG-1", "VR:12:REG-1", "12", "REG-1")), + List.of(new ExtractedVehicle("VIN-1", "VIN:VIN-1", "VIN-1")), + List.of(new ExtractedCardVehicleUsageInterval( + "CVU-2", + OffsetDateTime.parse("2026-05-02T08:00:00Z"), + OffsetDateTime.parse("2026-05-02T10:00:00Z"), + 200L, + 300L, + "12:REG-1", + "VIN-1", + "vu-2" + )), + List.of(new ExtractedCardActivityInterval( + "ACT-2", + OffsetDateTime.parse("2026-05-02T08:30:00Z"), + OffsetDateTime.parse("2026-05-02T09:00:00Z"), + "DRIVE", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-1", + "VIN-1", + "a2" + )), + List.of(new ExtractedSupportEvent( + "SUP-2", + "12:123", + OffsetDateTime.parse("2026-05-02T08:45:00Z"), + "POSITION", + "POSITION_RECORDED", + "SNAPSHOT", + "DRIVER", + "12:REG-1", + "VIN-1", + null, + null, + null, + null, + null, + BigDecimal.valueOf(48.2082), + BigDecimal.valueOf(16.3738), + "AUTHENTIC", + 250L, + null, + null, + null, + "raw-path-2" + )), + List.of() + ); + } + + private TachographFileSession session(DriverExtractionSession driver) { return new TachographFileSession( UUID.randomUUID(), diff --git a/src/test/java/at/procon/eventhub/processing/service/UnifiedRuntimeDriverTimelineServiceTest.java b/src/test/java/at/procon/eventhub/processing/service/UnifiedRuntimeDriverTimelineServiceTest.java index cb85529..d6744be 100644 --- a/src/test/java/at/procon/eventhub/processing/service/UnifiedRuntimeDriverTimelineServiceTest.java +++ b/src/test/java/at/procon/eventhub/processing/service/UnifiedRuntimeDriverTimelineServiceTest.java @@ -41,11 +41,17 @@ class UnifiedRuntimeDriverTimelineServiceTest { ResolvedDriverTimeline timeline = service.loadDriverTimeline( new UnifiedRuntimeProcessingRequest( + null, + List.of(), null, "default", Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB), UnifiedRuntimeEventBackend.SOURCE_DB, null, + Set.of(), + false, + Set.of(), + false, "DRIVER:42", null, null, diff --git a/src/test/java/at/procon/eventhub/processing/service/UnifiedRuntimeEventAssemblyServiceTest.java b/src/test/java/at/procon/eventhub/processing/service/UnifiedRuntimeEventAssemblyServiceTest.java index ffc7e3c..ec9f520 100644 --- a/src/test/java/at/procon/eventhub/processing/service/UnifiedRuntimeEventAssemblyServiceTest.java +++ b/src/test/java/at/procon/eventhub/processing/service/UnifiedRuntimeEventAssemblyServiceTest.java @@ -34,11 +34,17 @@ class UnifiedRuntimeEventAssemblyServiceTest { UnifiedRuntimeEventBundle bundle = service.assembleDriverScopedEvents( new UnifiedRuntimeProcessingRequest( + null, + List.of(), null, "default", Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB), UnifiedRuntimeEventBackend.SOURCE_DB, null, + Set.of(), + false, + Set.of(), + false, "DRIVER:42", null, null, diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCoreParityTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCoreParityTest.java new file mode 100644 index 0000000..0ec76d3 --- /dev/null +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCoreParityTest.java @@ -0,0 +1,173 @@ +package at.procon.eventhub.tachographfilesession.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.service.EventDetailsFactory; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractionStats; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class TachographEsperProcessingCoreParityTest { + + @Test + void fileSessionAndEventInputUseSameCoreAndProduceEquivalentProjectionSections() { + EventHubProperties properties = new EventHubProperties(); + DriverTimelineBuilder driverTimelineBuilder = new DriverTimelineBuilder(); + IntervalBackedDriverTimelineEventBuilder intervalEventBuilder = new IntervalBackedDriverTimelineEventBuilder( + driverTimelineBuilder, + new DriverKeyFactory(), + new VehicleKeyFactory(), + new EventDetailsFactory(new ObjectMapper()) + ); + RawSourceDriverTimelineEventBuilder rawSourceEventBuilder = new RawSourceDriverTimelineEventBuilder(intervalEventBuilder); + DriverTimelineReusableProjectionBuilder projectionBuilder = new DriverTimelineReusableProjectionBuilder( + driverTimelineBuilder, + rawSourceEventBuilder, + properties + ); + TachographEsperProcessingCore core = new TachographEsperProcessingCore( + driverTimelineBuilder, + projectionBuilder, + properties + ); + + DriverExtractionSession driver = driver(); + TachographFileSession session = session(driver); + ResolvedDriverTimeline timeline = driverTimelineBuilder.build(session, driver); + List rawEvents = rawSourceEventBuilder.buildRawEventBundle(session, driver).allEvents(); + + TachographEsperDriverProcessingResultDto fileSessionResult = core.process(TachographEsperProcessingInput.fromFileSession( + session, + driver, + timeline, + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-03T00:00:00Z"), + 3, + 420, + List.of("file-session-adapter") + )); + TachographEsperDriverProcessingResultDto eventInputResult = core.process(TachographEsperProcessingInput.fromEvents( + session.sessionId(), + driver.driverKey(), + timeline, + rawEvents, + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-03T00:00:00Z"), + 3, + 420, + List.of("runtime-adapter") + )); + + assertThat(eventInputResult.activityIntervalCount()).isEqualTo(fileSessionResult.activityIntervalCount()); + assertThat(eventInputResult.drivingIntervalCount()).isEqualTo(fileSessionResult.drivingIntervalCount()); + assertThat(eventInputResult.vehicleUsageIntervalCount()).isEqualTo(fileSessionResult.vehicleUsageIntervalCount()); + assertThat(eventInputResult.vuCardAbsentIntervalCount()).isEqualTo(fileSessionResult.vuCardAbsentIntervalCount()); + assertThat(eventInputResult.dailyWeeklyRestCandidateIntervalCount()) + .isEqualTo(fileSessionResult.dailyWeeklyRestCandidateIntervalCount()); + assertThat(eventInputResult.dailyWeeklyRestCandidateCoverageIntervalCount()) + .isEqualTo(fileSessionResult.dailyWeeklyRestCandidateCoverageIntervalCount()); + assertThat(eventInputResult.potentialInVehicleOvernightStayIntervalCount()) + .isEqualTo(fileSessionResult.potentialInVehicleOvernightStayIntervalCount()); + assertThat(eventInputResult.potentialHomeOvernightStayIntervalCount()) + .isEqualTo(fileSessionResult.potentialHomeOvernightStayIntervalCount()); + assertThat(eventInputResult.notes()).contains("runtime-adapter"); + } + + private DriverExtractionSession driver() { + return new DriverExtractionSession( + "12:123", + null, + null, + List.of(), + List.of(), + List.of( + new ExtractedCardVehicleUsageInterval( + "CVU-1", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T23:59:59Z"), + 100L, + 200L, + "12:REG-1", + "VIN-1", + "vu-1" + ), + new ExtractedCardVehicleUsageInterval( + "CVU-2", + OffsetDateTime.parse("2026-05-02T00:00:00Z"), + OffsetDateTime.parse("2026-05-02T10:00:00Z"), + 200L, + 260L, + "12:REG-1", + "VIN-1", + "vu-2" + ) + ), + List.of( + new ExtractedCardActivityInterval( + "ACT-1", + OffsetDateTime.parse("2026-05-01T08:30:00Z"), + OffsetDateTime.parse("2026-05-01T09:00:00Z"), + "DRIVE", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-1", + "VIN-1", + "a" + ), + new ExtractedCardActivityInterval( + "ACT-2", + OffsetDateTime.parse("2026-05-01T18:30:00Z"), + OffsetDateTime.parse("2026-05-01T18:45:00Z"), + "DRIVE", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-1", + "VIN-1", + "b" + ) + ), + List.of(), + List.of() + ); + } + + private TachographFileSession session(DriverExtractionSession driver) { + return new TachographFileSession( + UUID.randomUUID(), + new TachographFileSessionMetadata( + "default", + "legalrequirements-drivercard", + "sample", + "sample.ddd", + "a", + 2, + "42", + "b", + true, + null + ), + Map.of(driver.driverKey(), driver), + new ExtractionStats(1, 2, 2, 1, 1, 0), + List.of(), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + } +}