Add runtime event scope processing

This commit is contained in:
trifonovt 2026-05-25 22:42:11 +02:00
parent 3bccda20e8
commit b04b333db7
44 changed files with 4927 additions and 207 deletions

View File

@ -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.

View File

@ -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`.

View File

@ -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.

View File

@ -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"
]
}
}
}
]
}

View File

@ -1,15 +1,26 @@
package at.procon.eventhub.processing.api; 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.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.model.UnifiedRuntimeEventBundle;
import at.procon.eventhub.processing.service.UnifiedRuntimeDerivedProjectionService;
import at.procon.eventhub.processing.service.UnifiedRuntimeDriverTimelineService; import at.procon.eventhub.processing.service.UnifiedRuntimeDriverTimelineService;
import at.procon.eventhub.processing.service.UnifiedRuntimeEventAssemblyService; import at.procon.eventhub.processing.service.UnifiedRuntimeEventAssemblyService;
import at.procon.eventhub.processing.service.UnifiedRuntimeTachographEsperScopeProcessingService;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/eventhub/runtime-processing") @RequestMapping("/api/eventhub/runtime-processing")
@ -17,13 +28,31 @@ public class UnifiedRuntimeProcessingController {
private final UnifiedRuntimeEventAssemblyService eventAssemblyService; private final UnifiedRuntimeEventAssemblyService eventAssemblyService;
private final UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService; private final UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService;
private final UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService;
private final UnifiedRuntimeTachographEsperScopeProcessingService tachographEsperScopeProcessingService;
private final RuntimeEventProcessingService runtimeEventProcessingService;
public UnifiedRuntimeProcessingController( public UnifiedRuntimeProcessingController(
UnifiedRuntimeEventAssemblyService eventAssemblyService, 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.eventAssemblyService = eventAssemblyService;
this.runtimeDriverTimelineService = runtimeDriverTimelineService; this.runtimeDriverTimelineService = runtimeDriverTimelineService;
this.runtimeDerivedProjectionService = runtimeDerivedProjectionService;
this.tachographEsperScopeProcessingService = tachographEsperScopeProcessingService;
this.runtimeEventProcessingService = runtimeEventProcessingService;
} }
@PostMapping("/driver-events") @PostMapping("/driver-events")
@ -39,4 +68,45 @@ public class UnifiedRuntimeProcessingController {
) { ) {
return ResponseEntity.ok(runtimeDriverTimelineService.loadDriverTimeline(request.toRuntimeRequest())); return ResponseEntity.ok(runtimeDriverTimelineService.loadDriverTimeline(request.toRuntimeRequest()));
} }
@PostMapping("/driver-derived-projections")
public ResponseEntity<UnifiedRuntimeDerivedProjectionResultDto> loadDriverDerivedProjections(
@RequestBody UnifiedRuntimeProcessingApiRequest request
) {
return ResponseEntity.ok(runtimeDerivedProjectionService.loadDriverDerivedProjections(request));
}
@GetMapping("/event-processing/profiles")
public ResponseEntity<List<RuntimeEventProcessingProfileDescriptorDto>> listEventProcessingProfiles() {
if (runtimeEventProcessingService == null) {
throw new IllegalStateException("Runtime event processing service is not configured.");
}
return ResponseEntity.ok(runtimeEventProcessingService.listProfiles());
}
@PostMapping("/event-processing")
public ResponseEntity<RuntimeEventProcessingResultDto> 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<UnifiedRuntimeTachographEsperScopeResultDto> 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));
}
} }

View File

@ -1,6 +1,8 @@
package at.procon.eventhub.processing.api; 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.DriverNotFoundInSessionException;
import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionNotFoundException;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionNotFoundException; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionNotFoundException;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Map; import java.util.Map;
@ -14,7 +16,9 @@ public class UnifiedRuntimeProcessingExceptionHandler {
@ExceptionHandler({ @ExceptionHandler({
TachographFileSessionNotFoundException.class, TachographFileSessionNotFoundException.class,
DriverNotFoundInSessionException.class TachographCompositeSessionNotFoundException.class,
DriverNotFoundInSessionException.class,
DriverNotFoundInCompositeSessionException.class
}) })
public ResponseEntity<Map<String, Object>> notFound(RuntimeException exception) { public ResponseEntity<Map<String, Object>> notFound(RuntimeException exception) {
return error(HttpStatus.NOT_FOUND, exception); return error(HttpStatus.NOT_FOUND, exception);

View File

@ -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<UnifiedDiscoveredVehicleRef> discoveredVehicles,
TachographEsperDriverProcessingResultDto projection,
List<String> notes
) {
public UnifiedRuntimeDerivedProjectionResultDto {
discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles);
notes = notes == null ? List.of() : List.copyOf(notes);
}
}

View File

@ -4,30 +4,45 @@ import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
public record UnifiedRuntimeProcessingApiRequest( public record UnifiedRuntimeProcessingApiRequest(
UUID sessionId, UUID sessionId,
List<UUID> sessionIds,
UUID compositeSessionId,
String tenantKey, String tenantKey,
Set<UnifiedEventSourceFamily> sourceFamilies, Set<UnifiedEventSourceFamily> sourceFamilies,
UnifiedRuntimeEventBackend eventBackend, UnifiedRuntimeEventBackend eventBackend,
String driverKey, String driverKey,
Set<String> driverKeys,
Boolean includeAllDrivers,
Set<String> vehicleKeys,
Boolean includeAllVehicles,
String driverSourceEntityId, String driverSourceEntityId,
String driverCardNation, String driverCardNation,
String driverCardNumber, String driverCardNumber,
OffsetDateTime occurredFrom, OffsetDateTime occurredFrom,
OffsetDateTime occurredTo, OffsetDateTime occurredTo,
Boolean expandVehicleEvents, Boolean expandVehicleEvents,
Integer vehicleExpansionPaddingMinutes Integer vehicleExpansionPaddingMinutes,
Integer significantDrivingMinutes,
Integer minimumRestPeriodMinutes
) { ) {
public UnifiedRuntimeProcessingRequest toRuntimeRequest() { public UnifiedRuntimeProcessingRequest toRuntimeRequest() {
return new UnifiedRuntimeProcessingRequest( return new UnifiedRuntimeProcessingRequest(
sessionId, sessionId,
sessionIds,
compositeSessionId,
tenantKey, tenantKey,
sourceFamilies, sourceFamilies,
eventBackend, eventBackend,
driverKey, driverKey,
driverKeys,
includeAllDrivers != null && includeAllDrivers,
vehicleKeys,
includeAllVehicles != null && includeAllVehicles,
driverSourceEntityId, driverSourceEntityId,
driverCardNation, driverCardNation,
driverCardNumber, driverCardNumber,

View File

@ -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<UnifiedDiscoveredVehicleRef> discoveredVehicles,
Map<String, UnifiedRuntimeDerivedProjectionResultDto> driverResults,
List<String> notes,
List<String> 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<String, UnifiedRuntimeDerivedProjectionResultDto> driverResults = new LinkedHashMap<>();
for (Map.Entry<String, RuntimeEventProcessingPartitionResultDto> 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()
);
}
}

View File

@ -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<RuntimeEventProcessingProfileDescriptorDto> listProfiles() {
return profileRegistry.profileDescriptors();
}
}

View File

@ -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<String> partitionKeys,
Boolean includeAllPartitions,
Set<String> driverKeys,
Boolean includeAllDrivers,
Set<String> 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);
}
}

View File

@ -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<String, Object> 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()
);
}
}

View File

@ -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<String, Object> metadata
) {
public RuntimeEventProcessingPartitionResultDto {
metadata = metadata == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(metadata));
}
}

View File

@ -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<RuntimeEventPartitioningStrategy> supportedPartitioningStrategies,
Set<String> requiredParameters,
Set<String> 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()
);
}
}

View File

@ -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<UnifiedDiscoveredVehicleRef> discoveredVehicles,
Map<String, RuntimeEventProcessingPartitionResultDto> partitionResults,
List<String> notes,
List<String> 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);
}
}

View File

@ -0,0 +1,10 @@
package at.procon.eventhub.processing.eventprocessing.partition;
public enum RuntimeEventPartitioningStrategy {
NONE,
DRIVER,
VEHICLE,
DRIVER_VEHICLE,
SOURCE_FAMILY,
CUSTOM_PROFILE
}

View File

@ -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();
}
}

View File

@ -0,0 +1,9 @@
package at.procon.eventhub.processing.eventprocessing.partition;
public enum RuntimeEventScopeType {
DRIVER_SCOPED,
VEHICLE_SCOPED,
DRIVER_VEHICLE_SCOPED,
GLOBAL_SUPPORT,
UNKNOWN
}

View File

@ -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<RuntimeEventPartitioningStrategy> supportedPartitioningStrategies() {
RuntimeEventPartitioningStrategy defaultStrategy = defaultPartitioningStrategy();
return defaultStrategy == null ? List.of() : List.of(defaultStrategy);
}
default Set<String> requiredParameters() {
return Set.of();
}
default Set<String> optionalParameters() {
return Set.of();
}
RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request);
}

View File

@ -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<String, RuntimeEventProcessingProfile> profilesByKey;
public RuntimeEventProcessingProfileRegistry(List<RuntimeEventProcessingProfile> profiles) {
LinkedHashMap<String, RuntimeEventProcessingProfile> byKey = new LinkedHashMap<>();
for (RuntimeEventProcessingProfile profile : profiles == null ? List.<RuntimeEventProcessingProfile>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<String, RuntimeEventProcessingProfile> profilesByKey() {
return profilesByKey;
}
public List<RuntimeEventProcessingProfileDescriptorDto> profileDescriptors() {
return profilesByKey.values().stream()
.map(RuntimeEventProcessingProfileDescriptorDto::from)
.toList();
}
}

View File

@ -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<RuntimeEventPartitioningStrategy> supportedPartitioningStrategies() {
return List.of(RuntimeEventPartitioningStrategy.DRIVER);
}
@Override
public Set<String> 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<String, RuntimeEventProcessingPartitionResultDto> 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<String, Object> 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<String> driverKeys = scope.driverKeys();
boolean includeAllVehicles = scope.includeAllVehicles() != null && scope.includeAllVehicles();
java.util.Set<String> 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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}
}

View File

@ -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<EventHubEventDto> directDriverEvents,
List<EventHubEventDto> attachedVehicleEvidenceEvents,
List<EventHubEventDto> mergedEvents,
int vehicleUsageIntervalCount,
int candidateVehicleEvidenceEventCount,
int ignoredVehicleEvidenceEventCount,
List<String> notes,
List<String> 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);
}
}

View File

@ -36,9 +36,6 @@ public record UnifiedDriverEventsRequest(
} }
if (sourceFamily == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION) { if (sourceFamily == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION) {
Objects.requireNonNull(sessionId, "sessionId must not be null"); Objects.requireNonNull(sessionId, "sessionId must not be null");
if (driverKey == null) {
throw new IllegalArgumentException("driverKey must not be blank");
}
} else { } else {
if (tenantKey == null) { if (tenantKey == null) {
throw new IllegalArgumentException("tenantKey must not be blank"); 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( public static UnifiedDriverEventsRequest forTachographFileSession(
UUID sessionId, UUID sessionId,
String driverKey, String driverKey,

View File

@ -2,15 +2,24 @@ package at.procon.eventhub.processing.model;
import at.procon.eventhub.reference.DriverCardNumberNormalizer; import at.procon.eventhub.reference.DriverCardNumberNormalizer;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
public record UnifiedRuntimeProcessingRequest( public record UnifiedRuntimeProcessingRequest(
UUID sessionId, UUID sessionId,
List<UUID> sessionIds,
UUID compositeSessionId,
String tenantKey, String tenantKey,
Set<UnifiedEventSourceFamily> sourceFamilies, Set<UnifiedEventSourceFamily> sourceFamilies,
UnifiedRuntimeEventBackend eventBackend, UnifiedRuntimeEventBackend eventBackend,
String driverKey, String driverKey,
Set<String> driverKeys,
boolean includeAllDrivers,
Set<String> vehicleKeys,
boolean includeAllVehicles,
String driverSourceEntityId, String driverSourceEntityId,
String driverCardNation, String driverCardNation,
String driverCardNumber, String driverCardNumber,
@ -20,32 +29,64 @@ public record UnifiedRuntimeProcessingRequest(
int vehicleExpansionPaddingMinutes int vehicleExpansionPaddingMinutes
) { ) {
public UnifiedRuntimeProcessingRequest { 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()) { if (sourceFamilies == null || sourceFamilies.isEmpty()) {
throw new IllegalArgumentException("sourceFamilies must not be empty"); throw new IllegalArgumentException("sourceFamilies must not be empty");
} }
sourceFamilies = Set.copyOf(sourceFamilies); sourceFamilies = Set.copyOf(sourceFamilies);
eventBackend = eventBackend == null ? UnifiedRuntimeEventBackend.SOURCE_DB : eventBackend; eventBackend = eventBackend == null ? UnifiedRuntimeEventBackend.SOURCE_DB : eventBackend;
if (includesFileSession && sessionId == null) { sessionIds = normalizeSessionIds(sessionId, sessionIds);
throw new IllegalArgumentException("sessionId must not be null when TACHOGRAPH_FILE_SESSION is selected."); if (sessionId == null && !sessionIds.isEmpty()) {
sessionId = sessionIds.get(0);
} }
if (includesFileSession && driverKey == null) { if (compositeSessionId != null && !sessionIds.isEmpty()) {
throw new IllegalArgumentException("driverKey must not be blank when TACHOGRAPH_FILE_SESSION is selected."); throw new IllegalArgumentException("Use either compositeSessionId or sessionId/sessionIds, not both.");
} }
if (includesExternalDb && driverSourceEntityId == null && driverCardNumber == null) { driverKey = normalize(driverKey);
throw new IllegalArgumentException("At least one driver selector must be provided."); driverKeys = normalizeStrings(driverKeys);
if (driverKey != null) {
LinkedHashSet<String> 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)) { if (occurredFrom != null && occurredTo != null && occurredTo.isBefore(occurredFrom)) {
throw new IllegalArgumentException("occurredTo must not be before occurredFrom"); throw new IllegalArgumentException("occurredTo must not be before occurredFrom");
@ -116,11 +157,17 @@ public record UnifiedRuntimeProcessingRequest(
int vehicleExpansionPaddingMinutes int vehicleExpansionPaddingMinutes
) { ) {
return new UnifiedRuntimeProcessingRequest( return new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null, null,
tenantKey, tenantKey,
sourceFamilies, sourceFamilies,
eventBackend, eventBackend,
null, null,
Set.of(),
false,
Set.of(),
false,
driverSourceEntityId, driverSourceEntityId,
driverCardNation, driverCardNation,
driverCardNumber, driverCardNumber,
@ -143,11 +190,17 @@ public record UnifiedRuntimeProcessingRequest(
int vehicleExpansionPaddingMinutes int vehicleExpansionPaddingMinutes
) { ) {
return new UnifiedRuntimeProcessingRequest( return new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null, null,
tenantKey, tenantKey,
sourceFamilies, sourceFamilies,
UnifiedRuntimeEventBackend.EVENTHUB_DB, UnifiedRuntimeEventBackend.EVENTHUB_DB,
null, null,
Set.of(),
false,
Set.of(),
false,
driverSourceEntityId, driverSourceEntityId,
driverCardNation, driverCardNation,
driverCardNumber, driverCardNumber,
@ -168,10 +221,76 @@ public record UnifiedRuntimeProcessingRequest(
) { ) {
return new UnifiedRuntimeProcessingRequest( return new UnifiedRuntimeProcessingRequest(
sessionId, sessionId,
List.of(),
null,
null, null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
UnifiedRuntimeEventBackend.SOURCE_DB, UnifiedRuntimeEventBackend.SOURCE_DB,
driverKey, driverKey,
Set.of(),
false,
Set.of(),
false,
null,
null,
null,
occurredFrom,
occurredTo,
expandVehicleEvents,
vehicleExpansionPaddingMinutes
);
}
public static UnifiedRuntimeProcessingRequest forTachographFileSessions(
List<UUID> 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, null,
null, null,
@ -190,6 +309,58 @@ public record UnifiedRuntimeProcessingRequest(
return occurredTo == null ? null : occurredTo.plusMinutes(vehicleExpansionPaddingMinutes); return occurredTo == null ? null : occurredTo.plusMinutes(vehicleExpansionPaddingMinutes);
} }
private static List<UUID> normalizeSessionIds(UUID sessionId, List<UUID> sessionIds) {
LinkedHashSet<UUID> 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<String> normalizeStrings(Set<String> values) {
if (values == null || values.isEmpty()) {
return Set.of();
}
LinkedHashSet<String> 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) { private static String normalize(String value) {
return value == null || value.isBlank() ? null : value.trim(); return value == null || value.isBlank() ? null : value.trim();
} }

View File

@ -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<EventHubEventDto> directDriverEvents,
List<EventHubEventDto> runtimeScopeEvents,
boolean attachVehicleOnlyEvents,
int vehicleEvidencePaddingMinutes
) {
List<EventHubEventDto> safeDriverEvents = directDriverEvents == null ? List.of() : List.copyOf(directDriverEvents);
List<EventHubEventDto> safeScopeEvents = runtimeScopeEvents == null ? List.of() : List.copyOf(runtimeScopeEvents);
int paddingMinutes = Math.max(0, vehicleEvidencePaddingMinutes);
List<String> notes = new ArrayList<>();
List<String> 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<ResolvedVehicleUsageInterval> usageIntervals = mergeVehicleUsageIntervals(timeline.vehicleUsageIntervals());
List<EventHubEventDto> candidateVehicleEvidence = safeScopeEvents.stream()
.filter(event -> scopeClassifier.classify(event) == RuntimeEventScopeType.VEHICLE_SCOPED)
.toList();
List<EventHubEventDto> attached = new ArrayList<>();
int ignored = 0;
for (EventHubEventDto vehicleEvent : candidateVehicleEvidence) {
List<ResolvedVehicleUsageInterval> 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<ResolvedVehicleUsageInterval> matchingUsageIntervals(
EventHubEventDto vehicleEvent,
List<ResolvedVehicleUsageInterval> usageIntervals,
int paddingMinutes
) {
if (vehicleEvent == null || vehicleEvent.occurredAt() == null || usageIntervals == null || usageIntervals.isEmpty()) {
return List.of();
}
List<ResolvedVehicleUsageInterval> 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<String> eventKeys = vehicleKeys(event);
Set<String> intervalKeys = vehicleKeys(interval);
if (eventKeys.isEmpty() || intervalKeys.isEmpty()) {
return false;
}
return eventKeys.stream().anyMatch(intervalKeys::contains);
}
private Set<String> vehicleKeys(EventHubEventDto event) {
LinkedHashSet<String> 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<String> vehicleKeys(ResolvedVehicleUsageInterval interval) {
LinkedHashSet<String> 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<String> keys, String value) {
if (value != null && !value.isBlank()) {
keys.add(value.trim());
}
}
private List<ResolvedVehicleUsageInterval> mergeVehicleUsageIntervals(List<ResolvedVehicleUsageInterval> intervals) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
List<ResolvedVehicleUsageInterval> 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<ResolvedVehicleUsageInterval> 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<String> 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<EventHubEventDto> deduplicateAndSort(
List<EventHubEventDto> directDriverEvents,
List<EventHubEventDto> vehicleEvidenceEvents
) {
LinkedHashMap<String, EventHubEventDto> byKey = new LinkedHashMap<>();
appendDeduplicated(byKey, directDriverEvents);
appendDeduplicated(byKey, vehicleEvidenceEvents);
return sort(new ArrayList<>(byKey.values()));
}
private void appendDeduplicated(LinkedHashMap<String, EventHubEventDto> byKey, List<EventHubEventDto> events) {
for (EventHubEventDto event : events == null ? List.<EventHubEventDto>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<EventHubEventDto> sort(List<EventHubEventDto> events) {
return (events == null ? List.<EventHubEventDto>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();
}
}

View File

@ -7,7 +7,14 @@ import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.processing.model.UnifiedVehicleEventsRequest; 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.List;
import java.util.UUID;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@ -15,13 +22,22 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
private final UnifiedDriverEventSourceService driverEventSourceService; private final UnifiedDriverEventSourceService driverEventSourceService;
private final UnifiedVehicleEventSourceService vehicleEventSourceService; private final UnifiedVehicleEventSourceService vehicleEventSourceService;
private final TachographCompositeSessionRepository compositeSessionRepository;
private final EventAcquisitionRecordKeyService eventKeyService;
private final EventHubEventSorter eventSorter;
public TachographFileSessionRuntimeEventLoader( public TachographFileSessionRuntimeEventLoader(
UnifiedDriverEventSourceService driverEventSourceService, UnifiedDriverEventSourceService driverEventSourceService,
UnifiedVehicleEventSourceService vehicleEventSourceService UnifiedVehicleEventSourceService vehicleEventSourceService,
TachographCompositeSessionRepository compositeSessionRepository,
EventAcquisitionRecordKeyService eventKeyService,
EventHubEventSorter eventSorter
) { ) {
this.driverEventSourceService = driverEventSourceService; this.driverEventSourceService = driverEventSourceService;
this.vehicleEventSourceService = vehicleEventSourceService; this.vehicleEventSourceService = vehicleEventSourceService;
this.compositeSessionRepository = compositeSessionRepository;
this.eventKeyService = eventKeyService;
this.eventSorter = eventSorter;
} }
@Override @Override
@ -32,14 +48,18 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
@Override @Override
public List<EventHubEventDto> loadDriverEvents(UnifiedRuntimeProcessingRequest request) { public List<EventHubEventDto> loadDriverEvents(UnifiedRuntimeProcessingRequest request) {
return driverEventSourceService.loadDriverEvents( List<EventHubEventDto> result = new ArrayList<>();
for (UUID sessionId : resolveSessionIds(request)) {
result.addAll(driverEventSourceService.loadDriverEvents(
UnifiedDriverEventsRequest.forTachographFileSession( UnifiedDriverEventsRequest.forTachographFileSession(
request.sessionId(), sessionId,
request.driverKey(), request.driverKey(),
request.occurredFrom(), request.occurredFrom(),
request.occurredTo() request.occurredTo()
) )
); ));
}
return deduplicateBySignatureAndSort(result);
} }
@Override @Override
@ -47,9 +67,11 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
UnifiedRuntimeProcessingRequest request, UnifiedRuntimeProcessingRequest request,
UnifiedDiscoveredVehicleRef vehicleRef UnifiedDiscoveredVehicleRef vehicleRef
) { ) {
return vehicleEventSourceService.loadVehicleEvents( List<EventHubEventDto> result = new ArrayList<>();
for (UUID sessionId : resolveSessionIds(request)) {
result.addAll(vehicleEventSourceService.loadVehicleEvents(
UnifiedVehicleEventsRequest.forTachographFileSession( UnifiedVehicleEventsRequest.forTachographFileSession(
request.sessionId(), sessionId,
vehicleRef.sourceVehicleEntityId(), vehicleRef.sourceVehicleEntityId(),
vehicleRef.vin(), vehicleRef.vin(),
vehicleRef.registrationNation(), vehicleRef.registrationNation(),
@ -57,6 +79,25 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
request.vehicleOccurredFrom(), request.vehicleOccurredFrom(),
request.vehicleOccurredTo() request.vehicleOccurredTo()
) )
); ));
}
return deduplicateBySignatureAndSort(result);
}
private List<UUID> resolveSessionIds(UnifiedRuntimeProcessingRequest request) {
if (request.compositeSessionId() != null) {
return compositeSessionRepository.find(request.compositeSessionId())
.orElseThrow(() -> new TachographCompositeSessionNotFoundException(request.compositeSessionId()))
.memberSessionIds();
}
return request.sessionIds();
}
private List<EventHubEventDto> deduplicateBySignatureAndSort(List<EventHubEventDto> events) {
LinkedHashMap<String, EventHubEventDto> bySignature = new LinkedHashMap<>();
for (EventHubEventDto event : events) {
bySignature.putIfAbsent(eventKeyService.buildEventSignatureHash(event), event);
}
return eventSorter.sort(new ArrayList<>(bySignature.values()));
} }
} }

View File

@ -36,6 +36,12 @@ public class TachographFileSessionUnifiedDriverEventSource implements UnifiedDri
public List<EventHubEventDto> loadDriverEvents(UnifiedDriverEventsRequest request) { public List<EventHubEventDto> loadDriverEvents(UnifiedDriverEventsRequest request) {
TachographFileSession session = repository.find(request.sessionId()) TachographFileSession session = repository.find(request.sessionId())
.orElseThrow(() -> new TachographFileSessionNotFoundException(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()); DriverExtractionSession driver = session.driversByKey().get(request.driverKey());
if (driver == null) { if (driver == null) {
throw new DriverNotFoundInSessionException(request.sessionId(), request.driverKey()); throw new DriverNotFoundInSessionException(request.sessionId(), request.driverKey());

View File

@ -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<String> 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<EventHubEventDto> 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<TachographEsperVehicleUsageIntervalEvent> mergeVehicleUsageIntervals(
List<TachographEsperVehicleUsageIntervalEvent> intervals
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
List<TachographEsperVehicleUsageIntervalEvent> sorted = intervals.stream()
.sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt)
.thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(TachographEsperVehicleUsageIntervalEvent::intervalId, Comparator.nullsLast(String::compareTo)))
.toList();
List<TachographEsperVehicleUsageIntervalEvent> 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<String> 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<TachographEsperActivityIntervalEvent> clipActivityIntervals(
List<TachographEsperActivityIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return intervals == null ? List.of() : List.copyOf(intervals);
}
return (intervals == null ? List.<TachographEsperActivityIntervalEvent>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<TachographEsperVehicleUsageIntervalEvent> clipVehicleUsageIntervals(
List<TachographEsperVehicleUsageIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return intervals == null ? List.of() : List.copyOf(intervals);
}
return (intervals == null ? List.<TachographEsperVehicleUsageIntervalEvent>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<TachographEsperDrivingInterruptionIntervalEvent> clipDrivingIntervals(
List<TachographEsperDrivingInterruptionIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
return filterIntersecting(
intervals,
requestedFrom,
requestedTo,
TachographEsperDrivingInterruptionIntervalEvent::startedAt,
TachographEsperDrivingInterruptionIntervalEvent::endedAt
);
}
private List<TachographEsperVuCardAbsentIntervalEvent> clipVuCardAbsentIntervals(
List<TachographEsperVuCardAbsentIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
return filterIntersecting(
intervals,
requestedFrom,
requestedTo,
TachographEsperVuCardAbsentIntervalEvent::startedAt,
TachographEsperVuCardAbsentIntervalEvent::endedAt
);
}
private List<TachographEsperSupportGeoEvent> clipSupportGeoEvents(
List<ExtractedSupportEvent> supportEvents,
String driverKey,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
return (supportEvents == null ? List.<ExtractedSupportEvent>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 <T> List<T> filterIntersecting(
List<T> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo,
TimeAccessor<T> startAccessor,
TimeAccessor<T> 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<T> {
OffsetDateTime get(T value);
}
}

View File

@ -40,6 +40,15 @@ public class UnifiedRuntimeEventAssemblyService {
notes.add(request.eventBackend() == UnifiedRuntimeEventBackend.EVENTHUB_DB notes.add(request.eventBackend() == UnifiedRuntimeEventBackend.EVENTHUB_DB
? "Driver seed events were loaded from the local EventHub event store." ? "Driver seed events were loaded from the local EventHub event store."
: "Driver seed events were loaded directly from the selected runtime sources."); : "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()) { if (request.expandVehicleEvents()) {
notes.add("Vehicle expansion loaded additional events for vehicles discovered in the driver seed set."); notes.add("Vehicle expansion loaded additional events for vehicles discovered in the driver seed set.");
notes.add("Vehicle expansion padding minutes: " + request.vehicleExpansionPaddingMinutes() + "."); notes.add("Vehicle expansion padding minutes: " + request.vehicleExpansionPaddingMinutes() + ".");

View File

@ -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<String> selectedDriverKeys = selectedDriverKeys(request, broadBundle.mergedEvents());
if (selectedDriverKeys.isEmpty()) {
throw new IllegalArgumentException("No driver partitions could be resolved from the runtime event scope.");
}
Map<String, UnifiedRuntimeDerivedProjectionResultDto> driverResults = new LinkedHashMap<>();
Map<String, List<String>> attachedVehicleEvidenceByEvent = new LinkedHashMap<>();
List<String> 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<String> 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<String> selectedDriverKeys(
UnifiedRuntimeProcessingRequest request,
List<EventHubEventDto> events
) {
LinkedHashSet<String> allDrivers = discoverDriverKeys(events);
if (request.includeAllDrivers()) {
return allDrivers;
}
LinkedHashSet<String> requested = new LinkedHashSet<>(request.driverKeys());
if (request.driverKey() != null) {
requested.add(request.driverKey());
}
if (requested.isEmpty()) {
return allDrivers;
}
LinkedHashSet<String> 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<EventHubEventDto> 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<UnifiedDiscoveredVehicleRef> driverVehicles = discoverVehicles(attachmentResult.mergedEvents());
List<String> 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<String> discoverDriverKeys(List<EventHubEventDto> events) {
LinkedHashSet<String> result = new LinkedHashSet<>();
for (EventHubEventDto event : sort(events)) {
String driverKey = driverKey(event);
if (driverKey != null) {
result.add(driverKey);
}
}
return result;
}
private List<UnifiedDiscoveredVehicleRef> discoverVehicles(List<EventHubEventDto> events) {
List<UnifiedDiscoveredVehicleRef> 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<UnifiedDiscoveredVehicleRef> 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<EventHubEventDto> deduplicateAndSort(
List<EventHubEventDto> directDriverEvents,
List<EventHubEventDto> vehicleEvidenceEvents
) {
LinkedHashMap<String, EventHubEventDto> byKey = new LinkedHashMap<>();
appendDeduplicated(byKey, directDriverEvents);
appendDeduplicated(byKey, vehicleEvidenceEvents);
return sort(new ArrayList<>(byKey.values()));
}
private void appendDeduplicated(LinkedHashMap<String, EventHubEventDto> byKey, List<EventHubEventDto> 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<EventHubEventDto> sort(List<EventHubEventDto> events) {
return (events == null ? List.<EventHubEventDto>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();
}
}

View File

@ -61,26 +61,23 @@ public class DriverTimelineReusableProjectionBuilder {
private final DriverTimelineBuilder driverTimelineBuilder; private final DriverTimelineBuilder driverTimelineBuilder;
private final RawSourceDriverTimelineEventBuilder rawSourceEventBuilder; private final RawSourceDriverTimelineEventBuilder rawSourceEventBuilder;
private final UnifiedEventTimelineReconstructor timelineReconstructor;
private final EventHubProperties properties; private final EventHubProperties properties;
public DriverTimelineReusableProjectionBuilder( public DriverTimelineReusableProjectionBuilder(
DriverTimelineBuilder driverTimelineBuilder, DriverTimelineBuilder driverTimelineBuilder,
EventHubProperties properties EventHubProperties properties
) { ) {
this(driverTimelineBuilder, null, new UnifiedEventTimelineReconstructor(), properties); this(driverTimelineBuilder, null, properties);
} }
@Autowired @Autowired
public DriverTimelineReusableProjectionBuilder( public DriverTimelineReusableProjectionBuilder(
DriverTimelineBuilder driverTimelineBuilder, DriverTimelineBuilder driverTimelineBuilder,
RawSourceDriverTimelineEventBuilder rawSourceEventBuilder, RawSourceDriverTimelineEventBuilder rawSourceEventBuilder,
UnifiedEventTimelineReconstructor timelineReconstructor,
EventHubProperties properties EventHubProperties properties
) { ) {
this.driverTimelineBuilder = driverTimelineBuilder; this.driverTimelineBuilder = driverTimelineBuilder;
this.rawSourceEventBuilder = rawSourceEventBuilder; this.rawSourceEventBuilder = rawSourceEventBuilder;
this.timelineReconstructor = timelineReconstructor;
this.properties = properties; this.properties = properties;
} }
@ -325,20 +322,17 @@ public class DriverTimelineReusableProjectionBuilder {
String fallbackDriverKey, String fallbackDriverKey,
List<EventHubEventDto> events List<EventHubEventDto> events
) { ) {
UnifiedEventTimelineReconstructor timelineReconstructor = new UnifiedEventTimelineReconstructor();
ResolvedDriverTimeline reconstructed = timelineReconstructor.reconstruct( ResolvedDriverTimeline reconstructed = timelineReconstructor.reconstruct(
fallbackSessionId == null ? new UUID(0L, 0L) : fallbackSessionId, fallbackSessionId == null ? new UUID(0L, 0L) : fallbackSessionId,
fallbackDriverKey, fallbackDriverKey,
safeList(events) safeList(events)
); );
List<ResolvedVehicleUsageInterval> mergedVehicleUsageIntervals = mergeVehicleUsageIntervals(
reconstructed.vehicleUsageIntervals(),
reconstructed.sourceKind()
);
return new ResolvedDriverTimeline( return new ResolvedDriverTimeline(
reconstructed.sourceKind(), reconstructed.sourceKind(),
reconstructed.loadedFrom(), reconstructed.loadedFrom(),
reconstructed.loadedTo(), reconstructed.loadedTo(),
mergedVehicleUsageIntervals, mergeVehicleUsageIntervals(reconstructed.vehicleUsageIntervals(), reconstructed.sourceKind()),
reconstructed.activityIntervals(), reconstructed.activityIntervals(),
reconstructed.supportEvents(), reconstructed.supportEvents(),
reconstructed.warnings() reconstructed.warnings()
@ -411,7 +405,105 @@ public class DriverTimelineReusableProjectionBuilder {
int significantDrivingMinutes, int significantDrivingMinutes,
int minimumRestPeriodMinutes 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<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals = new ArrayList<>();
List<TachographEsperDrivingInterruptionIntervalEvent> dailyWeeklyRestCandidateIntervals = new ArrayList<>();
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> dailyWeeklyRestCandidateCoverageIntervals = new ArrayList<>();
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> unclassifiedDailyWeeklyRestCandidateCoverageIntervals = new ArrayList<>();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals = new ArrayList<>();
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals = new ArrayList<>();
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals = new ArrayList<>();
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> potentialInVehicleOvernightStayIntervals = new ArrayList<>();
List<TachographEsperPotentialInVehicleTripIntervalEvent> 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<String, Object> supportGeoEvidence : supportGeoInputEvents) {
runtime.getEventService().sendEventMap(
supportGeoEvidence,
"TachographSupportGeoEvidenceInputEvent"
);
}
}
if (vehicleUsagePointInputEvents != null) {
for (Map<String, Object> point : vehicleUsagePointInputEvents) {
runtime.getEventService().sendEventMap(
point,
"TachographVehicleUsagePointInputEvent"
);
}
for (Map<String, Object> finalizeEvent : buildProjectionFinalizeEvents(vehicleUsagePointInputEvents)) {
runtime.getEventService().sendEventMap(
finalizeEvent,
"TachographProjectionFinalizeEvent"
);
}
}
if (activityPointInputEvents != null) {
for (Map<String, Object> 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<Map<String, Object>> buildProjectionFinalizeEvents( private List<Map<String, Object>> buildProjectionFinalizeEvents(

View File

@ -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<TachographEsperActivityIntervalEvent> activityIntervals = clipEsperActivityIntervalEvents(
driverTimelineBuilder.buildEsperActivityIntervalEvents(input.sessionId(), driverKey, timeline),
requestedFrom,
requestedTo
);
List<TachographEsperActivityIntervalEvent> drivingIntervals = clipEsperActivityIntervalEvents(
driverTimelineBuilder.buildEsperDrivingIntervalEvents(input.sessionId(), driverKey, timeline),
requestedFrom,
requestedTo
);
TachographEsperDrivingDerivedProjectionBundle derivedProjectionBundle = buildDerivedProjection(
input,
timeline,
significantDrivingMinutes,
minimumRestPeriodMinutes
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionIntervals =
derivedProjectionBundle.drivingInterruptionIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals =
clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionIntervals, requestedFrom, requestedTo);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDailyWeeklyRestCandidateIntervals =
derivedProjectionBundle.dailyWeeklyRestCandidateIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> dailyWeeklyRestCandidateIntervals =
clipEsperDrivingInterruptionIntervalEvents(rawDailyWeeklyRestCandidateIntervals, requestedFrom, requestedTo);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionVehicleChangeIntervals =
derivedProjectionBundle.drivingInterruptionVehicleChangeIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals =
clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionVehicleChangeIntervals, requestedFrom, requestedTo);
List<TachographEsperVehicleUsageIntervalEvent> rawVehicleUsageIntervals =
driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline);
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals =
derivedProjectionBundle.vuCardAbsentIntervals();
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals =
clipEsperPotentialHomeOvernightStayIntervalEvents(
derivedProjectionBundle.potentialHomeOvernightStayIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> dailyWeeklyRestCandidateCoverageIntervals =
clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
derivedProjectionBundle.dailyWeeklyRestCandidateCoverageIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> unclassifiedDailyWeeklyRestCandidateCoverageIntervals =
clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
derivedProjectionBundle.unclassifiedDailyWeeklyRestCandidateCoverageIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> potentialInVehicleOvernightStayIntervals =
clipEsperPotentialInVehicleOvernightStayIntervalEvents(
derivedProjectionBundle.potentialInVehicleOvernightStayIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperPotentialInVehicleTripIntervalEvent> potentialInVehicleTripIntervals =
clipEsperPotentialInVehicleTripIntervalEvents(
derivedProjectionBundle.potentialInVehicleTripIntervals(),
potentialInVehicleOvernightStayIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVehicleUsageIntervalEvent> vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents(
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents(
rawVuCardAbsentIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperSupportGeoEvent> 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<String> combinedNotes(List<String> extraNotes) {
List<String> notes = new ArrayList<>();
notes.addAll(esperProjectionNotes());
if (extraNotes != null) {
notes.addAll(extraNotes);
}
return List.copyOf(notes);
}
private List<TachographEsperActivityIntervalEvent> clipEsperActivityIntervalEvents(
List<TachographEsperActivityIntervalEvent> 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<TachographEsperVehicleUsageIntervalEvent> clipEsperVehicleUsageIntervalEvents(
List<TachographEsperVehicleUsageIntervalEvent> 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<TachographEsperSupportGeoEvent> clipEsperSupportGeoEvents(
List<ExtractedSupportEvent> 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<TachographEsperDrivingInterruptionIntervalEvent> clipEsperDrivingInterruptionIntervalEvents(
List<TachographEsperDrivingInterruptionIntervalEvent> 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<TachographEsperVuCardAbsentIntervalEvent> clipEsperVuCardAbsentIntervalEvents(
List<TachographEsperVuCardAbsentIntervalEvent> 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<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> intervals,
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals,
List<TachographEsperVehicleUsageIntervalEvent> 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<TachographEsperPotentialHomeOvernightStayIntervalEvent> clipEsperPotentialHomeOvernightStayIntervalEvents(
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> intervals,
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals,
List<TachographEsperVehicleUsageIntervalEvent> 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<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> clipEsperPotentialInVehicleOvernightStayIntervalEvents(
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> intervals,
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals,
List<TachographEsperVehicleUsageIntervalEvent> 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<TachographEsperPotentialInVehicleTripIntervalEvent> clipEsperPotentialInVehicleTripIntervalEvents(
List<TachographEsperPotentialInVehicleTripIntervalEvent> intervals,
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> 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<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> 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<String> 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;
}
}

View File

@ -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<EventHubEventDto> eventInputEvents,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo,
int significantDrivingMinutes,
int minimumRestPeriodMinutes,
boolean forceEventInput,
List<String> 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<String> 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<EventHubEventDto> eventInputEvents,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo,
int significantDrivingMinutes,
int minimumRestPeriodMinutes,
List<String> notes
) {
return new TachographEsperProcessingInput(
sessionId,
driverKey,
null,
null,
timeline,
eventInputEvents,
requestedFrom,
requestedTo,
significantDrivingMinutes,
minimumRestPeriodMinutes,
true,
notes
);
}
public boolean hasEventInputEvents() {
return !eventInputEvents.isEmpty();
}
}

View File

@ -33,6 +33,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@ -42,6 +43,7 @@ public class TachographFileSessionProcessingService {
private final DriverTimelineBuilder driverTimelineBuilder; private final DriverTimelineBuilder driverTimelineBuilder;
private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder; private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder;
private final EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder; private final EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder;
private final TachographEsperProcessingCore esperProcessingCore;
private final EventHubProperties properties; private final EventHubProperties properties;
public TachographFileSessionProcessingService( public TachographFileSessionProcessingService(
@ -50,12 +52,32 @@ public class TachographFileSessionProcessingService {
DriverTimelineReusableProjectionBuilder reusableProjectionBuilder, DriverTimelineReusableProjectionBuilder reusableProjectionBuilder,
EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder, EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder,
EventHubProperties properties 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.repository = repository;
this.driverTimelineBuilder = driverTimelineBuilder; this.driverTimelineBuilder = driverTimelineBuilder;
this.reusableProjectionBuilder = reusableProjectionBuilder; this.reusableProjectionBuilder = reusableProjectionBuilder;
this.eventBackedDriverTimelineBuilder = eventBackedDriverTimelineBuilder; this.eventBackedDriverTimelineBuilder = eventBackedDriverTimelineBuilder;
this.properties = properties; this.properties = properties;
this.esperProcessingCore = esperProcessingCore;
} }
public TachographOperatingPeriodsProcessingResultDto evaluateOperatingPeriods( public TachographOperatingPeriodsProcessingResultDto evaluateOperatingPeriods(
@ -156,160 +178,26 @@ public class TachographFileSessionProcessingService {
} }
ResolvedDriverTimeline timeline = resolveTimeline(session, driver); ResolvedDriverTimeline timeline = resolveTimeline(session, driver);
OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null ? timeline.loadedFrom() : utc(effectiveRequest.occurredFrom()); OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null
OffsetDateTime requestedTo = effectiveRequest.occurredTo() == null ? timeline.loadedTo() : utc(effectiveRequest.occurredTo()); ? timeline.loadedFrom()
: utc(effectiveRequest.occurredFrom());
OffsetDateTime requestedTo = effectiveRequest.occurredTo() == null
? timeline.loadedTo()
: utc(effectiveRequest.occurredTo());
if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) { if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) {
throw new IllegalArgumentException("occurredTo must not be before occurredFrom."); throw new IllegalArgumentException("occurredTo must not be before occurredFrom.");
} }
int significantDrivingMinutes = resolveEsperSignificantDrivingMinutes(effectiveRequest);
int minimumRestPeriodMinutes = resolveMinimumRestPeriodMinutes(effectiveRequest);
List<TachographEsperActivityIntervalEvent> activityIntervals = clipEsperActivityIntervalEvents( return esperProcessingCore.process(TachographEsperProcessingInput.fromFileSession(
driverTimelineBuilder.buildEsperActivityIntervalEvents(sessionId, driverKey, timeline),
requestedFrom,
requestedTo
);
List<TachographEsperActivityIntervalEvent> drivingIntervals = clipEsperActivityIntervalEvents(
driverTimelineBuilder.buildEsperDrivingIntervalEvents(sessionId, driverKey, timeline),
requestedFrom,
requestedTo
);
TachographEsperDrivingDerivedProjectionBundle derivedProjectionBundle =
properties.getTachographFileSession().getProcessing().getDrivingDerivedProjectionInputMode()
== EventHubProperties.DrivingDerivedProjectionInputMode.EVENTS
? reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle(
session, session,
driver, driver,
significantDrivingMinutes,
minimumRestPeriodMinutes
)
: reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle(
sessionId,
driverKey,
timeline, timeline,
significantDrivingMinutes,
minimumRestPeriodMinutes
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionIntervals =
derivedProjectionBundle.drivingInterruptionIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDrivingInterruptionIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDailyWeeklyRestCandidateIntervals =
derivedProjectionBundle.dailyWeeklyRestCandidateIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> dailyWeeklyRestCandidateIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDailyWeeklyRestCandidateIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionVehicleChangeIntervals =
derivedProjectionBundle.drivingInterruptionVehicleChangeIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDrivingInterruptionVehicleChangeIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVehicleUsageIntervalEvent> rawVehicleUsageIntervals =
driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline);
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals =
derivedProjectionBundle.vuCardAbsentIntervals();
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals =
clipEsperPotentialHomeOvernightStayIntervalEvents(
derivedProjectionBundle.potentialHomeOvernightStayIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> dailyWeeklyRestCandidateCoverageIntervals =
clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
derivedProjectionBundle.dailyWeeklyRestCandidateCoverageIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> unclassifiedDailyWeeklyRestCandidateCoverageIntervals =
clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
derivedProjectionBundle.unclassifiedDailyWeeklyRestCandidateCoverageIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> potentialInVehicleOvernightStayIntervals =
clipEsperPotentialInVehicleOvernightStayIntervalEvents(
derivedProjectionBundle.potentialInVehicleOvernightStayIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperPotentialInVehicleTripIntervalEvent> potentialInVehicleTripIntervals =
clipEsperPotentialInVehicleTripIntervalEvents(
derivedProjectionBundle.potentialInVehicleTripIntervals(),
potentialInVehicleOvernightStayIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVehicleUsageIntervalEvent> vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents(
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents(
rawVuCardAbsentIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperSupportGeoEvent> supportGeoEvents = clipEsperSupportGeoEvents(
timeline.supportEvents(),
driverKey,
requestedFrom,
requestedTo
);
return new TachographEsperDriverProcessingResultDto(
sessionId,
driverKey,
timeline.sourceKind(),
timeline.loadedFrom(),
timeline.loadedTo(),
requestedFrom, requestedFrom,
requestedTo, requestedTo,
activityIntervals.size(), resolveEsperSignificantDrivingMinutes(effectiveRequest),
drivingIntervals.size(), resolveMinimumRestPeriodMinutes(effectiveRequest),
drivingInterruptionIntervals.size(), List.of()
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()
);
} }
private ResolvedDriverTimeline resolveTimeline( private ResolvedDriverTimeline resolveTimeline(

View File

@ -2,6 +2,7 @@ package at.procon.eventhub.processing.api;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when; 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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 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.EventType;
import at.procon.eventhub.dto.VehicleRefDto; import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto; 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.UnifiedDiscoveredVehicleRef;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; 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.UnifiedRuntimeDriverTimelineService;
import at.procon.eventhub.processing.service.UnifiedRuntimeEventAssemblyService; 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.ResolvedActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval; import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval;
@ -48,7 +57,8 @@ class UnifiedRuntimeProcessingControllerTest {
void loadsDriverEventsViaRuntimeApi() throws Exception { void loadsDriverEventsViaRuntimeApi() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class); UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.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)) .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build(); .build();
@ -98,7 +108,8 @@ class UnifiedRuntimeProcessingControllerTest {
void loadsDriverTimelineViaRuntimeApi() throws Exception { void loadsDriverTimelineViaRuntimeApi() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class); UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.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)) .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build(); .build();
@ -162,11 +173,286 @@ class UnifiedRuntimeProcessingControllerTest {
.andExpect(jsonPath("$.vehicleUsageIntervals[0].intervalId").value("CVU-1")); .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 @Test
void returnsBadRequestForInvalidRuntimeRequest() throws Exception { void returnsBadRequestForInvalidRuntimeRequest() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class); UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.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)) .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build(); .build();

View File

@ -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()
);
}
}
}

View File

@ -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<String> optionalParameters() {
return Set.of("thresholdMinutes");
}
@Override
public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) {
throw new UnsupportedOperationException("Not needed by this test");
}
}
}

View File

@ -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<UnifiedRuntimeProcessingApiRequest> 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();
}
}

View File

@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -89,14 +90,141 @@ class UnifiedRuntimeProcessingRequestTest {
assertThat(request.eventBackend()).isEqualTo(UnifiedRuntimeEventBackend.SOURCE_DB); 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 @Test
void rejectsRequestWithoutDriverSelector() { void rejectsRequestWithoutDriverSelector() {
assertThatThrownBy(() -> new UnifiedRuntimeProcessingRequest( assertThatThrownBy(() -> new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null, null,
"default", "default",
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB), Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB),
UnifiedRuntimeEventBackend.SOURCE_DB, UnifiedRuntimeEventBackend.SOURCE_DB,
null, null,
Set.of(),
false,
Set.of(),
false,
null, null,
null, null,
null, null,

View File

@ -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<EventHubEventDto> 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<EventHubEventDto> 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<EventHubEventDto> 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<EventHubEventDto> 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;
}
}

View File

@ -5,7 +5,9 @@ import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.config.EventHubProperties; import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef; import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.service.EventAcquisitionRecordKeyService;
import at.procon.eventhub.service.EventDetailsFactory; 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.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval; import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; 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.model.TachographFileSessionMetadata;
import at.procon.eventhub.tachographfilesession.service.DriverKeyFactory; import at.procon.eventhub.tachographfilesession.service.DriverKeyFactory;
import at.procon.eventhub.tachographfilesession.service.DriverTimelineBuilder; 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.InMemoryTachographFileSessionRepository;
import at.procon.eventhub.tachographfilesession.service.IntervalBackedDriverTimelineEventBuilder; import at.procon.eventhub.tachographfilesession.service.IntervalBackedDriverTimelineEventBuilder;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository;
@ -39,6 +43,7 @@ class TachographFileSessionRuntimeEventLoaderTest {
void loadsDriverAndVehicleEventsFromFileSessionRuntimePath() { void loadsDriverAndVehicleEventsFromFileSessionRuntimePath() {
EventHubProperties properties = new EventHubProperties(); EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
InMemoryTachographCompositeSessionRepository compositeRepository = new InMemoryTachographCompositeSessionRepository();
IntervalBackedDriverTimelineEventBuilder eventBuilder = new IntervalBackedDriverTimelineEventBuilder( IntervalBackedDriverTimelineEventBuilder eventBuilder = new IntervalBackedDriverTimelineEventBuilder(
new DriverTimelineBuilder(), new DriverTimelineBuilder(),
new DriverKeyFactory(), new DriverKeyFactory(),
@ -47,7 +52,10 @@ class TachographFileSessionRuntimeEventLoaderTest {
); );
TachographFileSessionRuntimeEventLoader loader = new TachographFileSessionRuntimeEventLoader( TachographFileSessionRuntimeEventLoader loader = new TachographFileSessionRuntimeEventLoader(
new UnifiedDriverEventSourceService(List.of(new TachographFileSessionUnifiedDriverEventSource(repository, eventBuilder))), 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(); DriverExtractionSession driver = driver();
@ -68,6 +76,56 @@ class TachographFileSessionRuntimeEventLoaderTest {
assertThat(loader.loadVehicleEvents(request, new UnifiedDiscoveredVehicleRef("VIN-1", "VIN-1", "12", "REG-1"))).hasSize(5); 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() { private DriverExtractionSession driver() {
return new DriverExtractionSession( return new DriverExtractionSession(
"12:123", "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) { private TachographFileSession session(DriverExtractionSession driver) {
return new TachographFileSession( return new TachographFileSession(
UUID.randomUUID(), UUID.randomUUID(),

View File

@ -41,11 +41,17 @@ class UnifiedRuntimeDriverTimelineServiceTest {
ResolvedDriverTimeline timeline = service.loadDriverTimeline( ResolvedDriverTimeline timeline = service.loadDriverTimeline(
new UnifiedRuntimeProcessingRequest( new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null, null,
"default", "default",
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB), Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB),
UnifiedRuntimeEventBackend.SOURCE_DB, UnifiedRuntimeEventBackend.SOURCE_DB,
null, null,
Set.of(),
false,
Set.of(),
false,
"DRIVER:42", "DRIVER:42",
null, null,
null, null,

View File

@ -34,11 +34,17 @@ class UnifiedRuntimeEventAssemblyServiceTest {
UnifiedRuntimeEventBundle bundle = service.assembleDriverScopedEvents( UnifiedRuntimeEventBundle bundle = service.assembleDriverScopedEvents(
new UnifiedRuntimeProcessingRequest( new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null, null,
"default", "default",
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB), Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB),
UnifiedRuntimeEventBackend.SOURCE_DB, UnifiedRuntimeEventBackend.SOURCE_DB,
null, null,
Set.of(),
false,
Set.of(),
false,
"DRIVER:42", "DRIVER:42",
null, null,
null, null,

View File

@ -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<EventHubEventDto> 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)
);
}
}