Compare commits

...

8 Commits

Author SHA1 Message Date
trifonovt 3c5ce9a066 Extend runtime event mixing diagnostics 2026-06-15 15:50:26 +02:00
trifonovt 74d479454a Refine runtime event mixing compatibility 2026-06-15 15:43:28 +02:00
trifonovt e24df88736 Extract driver working time Esper contract names 2026-06-15 13:47:01 +02:00
trifonovt e45fe29d3f Refine reusable driver working time projections 2026-06-15 13:14:43 +02:00
trifonovt cdec89aa69 Update reusable driver working time projections 2026-06-15 13:07:41 +02:00
trifonovt dd5c32f44f Refine runtime event aggregation 2026-06-15 12:53:54 +02:00
trifonovt 729e6fb261 Add runtime tachograph aggregation and parity tests 2026-06-15 12:22:23 +02:00
trifonovt c1b4847cf0 Refine runtime event assembly scope 2026-06-15 11:14:17 +02:00
43 changed files with 3066 additions and 459 deletions

View File

@ -1,109 +1,40 @@
# Patch: Vehicle Usage Interval Reconciliation
# Cross-representation tachograph event mixing fix
This patch extends the already introduced runtime event-mixing architecture with an interval-level reconciliation step for tachograph vehicle-usage evidence.
## Problem
## New module
A runtime request that loads both `TACHOGRAPH_FILE_SESSION` and `TACHOGRAPH_DB` can contain the same tachograph observation twice. The earlier mixing implementation primarily handled CARD versus VU evidence. It did not reliably classify the semantic source role and physical representation of every event, and it did not suppress duplicate observations when both copies had the same source role (for example VU file-session plus VU database serialization).
Added runtime module:
This produced nearly doubled activity and support-event results in mixed executions.
```text
vehicle-usage-reconciliation
```
## Implementation
It runs after:
- Added semantic tachograph evidence source roles:
- `DRIVER_CARD`
- `VEHICLE_UNIT`
- `UNKNOWN`
- Added physical representation classification:
- `FILE_SESSION`
- `DATABASE`
- `UNKNOWN`
- Source-role inference now uses extraction code, raw source metadata, package event-source metadata, package kind and external event identifiers.
- Database runtime packages identified by `RUNTIME:TACHOGRAPH:*` take precedence over retained original file-session package metadata.
- Existing CARD/VU rules now use semantic source roles, with extraction codes retained only as fallback for unclassified events.
- Added same-source-role cross-representation rules for activity and support evidence:
- database representation is retained as primary;
- matching file-session representation is suppressed as a duplicate;
- exact timestamps remain required;
- semantic compatibility checks still protect meaningful conflicts.
- `CARD_VEHICLES_USED` and `IW_CYCLE` remain untouched by event mixing and continue to be handled by interval-level reconciliation.
- Added mixing diagnostics for source roles, representations, candidate groups, rejected compatibility pairs and suppressed events.
- Fixed tachograph place SQL so entry type `6` is classified as `START`, consistently with file-session parsing.
```text
event-to-vehicle-usage-intervals
```
## Tests added/updated
and before:
- Same VU place observation from file session and database is reduced to one event.
- Same CARD activity observation from file session and database is reduced to one event.
- Database/file-session representation classification remains correct even when DB rows retain original file-session package metadata.
- Existing CARD/VU parity and CVU/IW preservation tests remain covered.
```text
vehicle-usage-merge
```
## Validation
## Main behavior
The module intentionally does not mix `CARD_VEHICLES_USED` and `IW_CYCLE` at event level. Instead, it reconciles the completed vehicle-usage intervals.
Processing phases:
1. Split raw vehicle-usage intervals by source type:
- `CARD_VEHICLES_USED`
- `IW_CYCLE`
- `OTHER`
2. Normalize `CARD_VEHICLES_USED` technical midnight splits.
3. Reconcile normalized `CARD_VEHICLES_USED` intervals with `IW_CYCLE` intervals.
4. Produce effective vehicle-usage intervals for downstream processing.
## CVU technical midnight split
The technical midnight split is handled only for `CARD_VEHICLES_USED` / CVU intervals, not for `IW_CYCLE`.
Pattern:
```text
CARD_VEHICLES_USED interval A ends at 23:59:59
CARD_VEHICLES_USED interval B starts at 00:00:00
same driver
same registration / compatible vehicle
max gap: 1 second
```
Result:
```text
A + B => one normalized CARD_VEHICLES_USED interval
```
## CVU vs IW reconciliation
After CVU normalization:
```text
normalized CARD_VEHICLES_USED interval
vs
IW_CYCLE interval
```
Rule:
```text
IW_CYCLE is primary for effective vehicle-usage identity.
CARD_VEHICLES_USED is fallback or corroborating evidence.
```
Matching currently supports exact or compatible start/end boundaries with a 60-second tolerance.
## New classes
```text
src/main/java/at/procon/eventhub/processing/eventprocessing/module/VehicleUsageReconciliationModule.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageIntervalDescriptor.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageIntervalDescriptorFactory.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageIntervalRole.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageIntervalSourceType.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageReconciliationDecisionDto.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageReconciliationResult.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageReconciliationService.java
src/test/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageReconciliationServiceTest.java
```
## Modified existing files
```text
src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeModuleKeys.java
src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageMergeModule.java
src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverWorkingTimeRuntimeProcessingPlan.java
```
## Notes
`vehicle-usage-merge` now consumes the effective intervals from `vehicle-usage-reconciliation` when that module has run. If the reconciliation module is omitted from a custom module list, `vehicle-usage-merge` falls back to raw `event-to-vehicle-usage-intervals` output.
Tests were added for:
- CVU technical midnight split coalescing
- CVU + IW reconciliation with IW as primary
- CVU fallback when IW is missing
- IW primary when CVU is missing
The modified Java files were passed through a `javac` parser check. Only expected missing dependency/classpath errors were reported; no Java syntax errors were detected. Maven and a Maven wrapper are unavailable in the execution environment, so the full project test suite was not executed.

View File

@ -79,6 +79,20 @@ tachograph-driving-derived-projection-bundle.epl
New runtime-processing code should use the driver-working-time names.
The common Esper contract is source-neutral as well:
```text
DriverWorkingTimeActivityPointInputEvent
DriverWorkingTimeVehicleUsagePointInputEvent
DriverWorkingTimeActivityIntervalInputEvent
DriverWorkingTimeVehicleUsageIntervalInputEvent
DriverWorkingTimeSupportEvidenceInputEvent
DriverWorkingTimeProjectionFinalizeEvent
DriverWorkingTimeVehicleUsageIntervalInputWindow
```
Tachograph-prefixed Esper types remain only inside the compatibility resources listed above.
## EPL-backed phase modules
The driver working-time plan now contains first-class EPL-backed phase modules for event-to-interval conversion:

View File

@ -168,6 +168,10 @@ RuntimeDriverWorkingTimeScopeProcessingService
Legacy tachograph names are kept as compatibility adapters for existing file-session endpoints and Postman requests. New runtime code should use the `driver-working-time-*` classes/resources and the `driver-working-time-v1` processing plan.
The active common EPL contracts use `DriverWorkingTime*InputEvent` and
`DriverWorkingTimeVehicleUsageIntervalInputWindow` names. Tachograph-prefixed Esper input
contracts are limited to legacy compatibility resources.
## Module execution results
`/api/eventhub/runtime-processing/executions` now exposes module execution metadata explicitly.

View File

@ -0,0 +1,38 @@
# Tachograph DB / file-session runtime parity
The runtime pipeline treats direct tachograph file-session events and events loaded from the tachograph database as two representations of the same tachograph facts.
## Canonical semantic boundary
`RuntimeTachographEventSemantics` normalizes representation-only differences without modifying the original event:
- file-session and DB source systems are exposed as `TACHOGRAPH`;
- extraction codes are read from DB raw metadata or inferred from source kind and event domain;
- `PLACE START` and `PLACE BEGIN` share the semantic lifecycle `BEGIN` for mixing.
The raw lifecycle, source package, payload, and external source event ID remain unchanged for audit and provenance.
## Runtime aggregation
`RuntimeEventAggregationService` is shared by:
- `TachographFileSessionRuntimeEventLoader`;
- `TachographDbRuntimeEventLoader`;
- `UnifiedRuntimeEventAssemblyService`.
Aggregation removes:
- repeated reads of the same source record;
- duplicate serialized representations of the same extraction observation.
It deliberately preserves evidence pairs that are resolved downstream:
- `CARD_ACTIVITY` / `VU_ACTIVITY`;
- card/VU support evidence;
- `CARD_VEHICLES_USED` / `IW_CYCLE`.
The first two are handled by `RuntimeEventMixingService`. Vehicle usage remains source-distinct until interval-level reconciliation.
## Regression coverage
`RuntimeTachographRepresentationParityTest` verifies equal source profiles and mixing outcomes for DB and file-session representations. `RuntimeEventAggregationServiceTest` verifies that technical duplicates are reduced while card/VU and CVU/IW evidence remains available to later modules.

View File

@ -0,0 +1,28 @@
package at.procon.eventhub.processing.driverworkingtime.esper;
/**
* Canonical Esper type and state names used by the source-neutral driver-working-time pipeline.
*
* <p>Source-specific adapters may keep their own compatibility names, but common runtime modules
* must use these contracts after source events have been normalized.</p>
*/
public final class DriverWorkingTimeEsperContractNames {
public static final String ACTIVITY_POINT_INPUT_EVENT_TYPE =
"DriverWorkingTimeActivityPointInputEvent";
public static final String VEHICLE_USAGE_POINT_INPUT_EVENT_TYPE =
"DriverWorkingTimeVehicleUsagePointInputEvent";
public static final String ACTIVITY_INTERVAL_INPUT_EVENT_TYPE =
"DriverWorkingTimeActivityIntervalInputEvent";
public static final String VEHICLE_USAGE_INTERVAL_INPUT_EVENT_TYPE =
"DriverWorkingTimeVehicleUsageIntervalInputEvent";
public static final String SUPPORT_EVIDENCE_INPUT_EVENT_TYPE =
"DriverWorkingTimeSupportEvidenceInputEvent";
public static final String PROJECTION_FINALIZE_EVENT_TYPE =
"DriverWorkingTimeProjectionFinalizeEvent";
public static final String VEHICLE_USAGE_INTERVAL_INPUT_WINDOW =
"DriverWorkingTimeVehicleUsageIntervalInputWindow";
private DriverWorkingTimeEsperContractNames() {
}
}

View File

@ -11,6 +11,7 @@ import com.espertech.esper.runtime.client.EPDeployment;
import com.espertech.esper.runtime.client.EPRuntime;
import com.espertech.esper.runtime.client.EPRuntimeProvider;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.processing.driverworkingtime.esper.DriverWorkingTimeEsperContractNames;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeDerivedProjectionBundle;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeDrivingInterruptionInterval;
@ -58,7 +59,8 @@ public class DriverWorkingTimeReusableProjectionBuilder {
private static final Map<String, Object> VEHICLE_USAGE_INTERVAL_INPUT_DEFINITION = vehicleUsageIntervalInputDefinitionStatic();
private static final Map<String, Object> SUPPORT_GEO_EVIDENCE_INPUT_DEFINITION = supportGeoEvidenceInputDefinitionStatic();
private static final int MAX_IDLE_RUNTIMES_PER_DEFINITION = 2;
private static final List<String> REUSABLE_RUNTIME_STATE_CLEANUP_QUERIES = List.of(
static final List<String> REUSABLE_RUNTIME_STATE_CLEANUP_QUERIES = List.of(
"delete from " + DriverWorkingTimeEsperContractNames.VEHICLE_USAGE_INTERVAL_INPUT_WINDOW,
"delete from PreviousRestCandidateCoverageInterval",
"delete from OpenPotentialInVehicleTripState",
"delete from SupportGeoEvidenceWindow",
@ -76,6 +78,7 @@ public class DriverWorkingTimeReusableProjectionBuilder {
"delete from DailyWeeklyRestCandidateEndBoundaryOdometerResolvedWindow",
"delete from DailyWeeklyRestCandidateCoverageCardResolvedIntervalWindow",
"delete from DailyWeeklyRestCandidateCoverageEmittedKeyWindow",
"delete from VuCardAbsentIntervalWindow",
"delete from PreviousSignificantDrivingInterval",
"context PerDriver delete from PreviousVehicleUsageInterval"
);
@ -185,7 +188,7 @@ public class DriverWorkingTimeReusableProjectionBuilder {
long sortOutputMs = elapsedMillis(sortOutputStartedAtNanos);
LOG.info(
"Driver working-time derived projection bundle built in {} ms (definitionCacheHit: {}, definitionPreparationMs: {}, runtimePoolHit: {}, runtimeInitMs: {}, deployMs: {}, listenerRegistrationMs: {}, runtimeResetMs: {}, sendSupportGeoMs: {}, sendVehicleUsageMs: {}, sendActivityMs: {}, destroyMs: {}, sortOutputMs: {}, activityInputEvents: {}, vehicleUsageInputEvents: {}, supportGeoInputEvents: {})",
"Driver working-time derived projection bundle built in {} ms (definitionCacheHit: {}, definitionPreparationMs: {}, runtimePoolHit: {}, runtimeInitMs: {}, deployMs: {}, listenerRegistrationMs: {}, runtimeResetBeforeMs: {}, runtimeResetAfterMs: {}, sendSupportGeoMs: {}, sendVehicleUsageMs: {}, sendActivityMs: {}, destroyMs: {}, sortOutputMs: {}, activityInputEvents: {}, vehicleUsageInputEvents: {}, supportGeoInputEvents: {})",
elapsedMillis(startedAtNanos),
runtimeMetrics.definitionCacheHit(),
runtimeMetrics.definitionPreparationMs(),
@ -193,7 +196,8 @@ public class DriverWorkingTimeReusableProjectionBuilder {
runtimeMetrics.runtimeInitMs(),
runtimeMetrics.deployMs(),
runtimeMetrics.listenerRegistrationMs(),
runtimeMetrics.runtimeResetMs(),
runtimeMetrics.runtimeResetBeforeMs(),
runtimeMetrics.runtimeResetAfterMs(),
runtimeMetrics.sendSupportGeoMs(),
runtimeMetrics.sendVehicleUsageMs(),
runtimeMetrics.sendActivityMs(),
@ -287,7 +291,8 @@ public class DriverWorkingTimeReusableProjectionBuilder {
long runtimeInitMs = 0L;
long deployMs = 0L;
long listenerRegistrationMs = 0L;
long runtimeResetMs = 0L;
long runtimeResetBeforeMs = 0L;
long runtimeResetAfterMs = 0L;
long sendSupportGeoMs = 0L;
long sendVehicleUsageMs = 0L;
long sendActivityMs = 0L;
@ -329,14 +334,15 @@ public class DriverWorkingTimeReusableProjectionBuilder {
listenerRegistrationMs = reusableRuntime.listenerRegistrationMs();
ReusableProjectionRuntimeExecution execution = reusableRuntime.execute(listeners, sender);
runtimeResetMs = execution.runtimeResetMs();
runtimeResetBeforeMs = execution.runtimeResetBeforeMs();
runtimeResetAfterMs = execution.runtimeResetAfterMs();
sendSupportGeoMs = execution.sendSupportGeoMs();
sendVehicleUsageMs = execution.sendVehicleUsageMs();
sendActivityMs = execution.sendActivityMs();
} catch (EPCompileException | EPDeployException e) {
discardRuntime = true;
throw new IllegalStateException("Cannot compile/deploy reusable driver working-time projection EPL bundle", e);
} catch (RuntimeException e) {
} catch (RuntimeException | Error e) {
discardRuntime = true;
throw e;
} finally {
@ -355,7 +361,8 @@ public class DriverWorkingTimeReusableProjectionBuilder {
runtimeInitMs,
deployMs,
listenerRegistrationMs,
runtimeResetMs,
runtimeResetBeforeMs,
runtimeResetAfterMs,
sendSupportGeoMs,
sendVehicleUsageMs,
sendActivityMs,
@ -434,15 +441,15 @@ public class DriverWorkingTimeReusableProjectionBuilder {
private Configuration createRuntimeConfiguration() {
Configuration configuration = new Configuration();
configuration.getCommon().addEventType(
"TachographActivityIntervalInputEvent",
DriverWorkingTimeEsperContractNames.ACTIVITY_INTERVAL_INPUT_EVENT_TYPE,
activityIntervalInputDefinition()
);
configuration.getCommon().addEventType(
"TachographVehicleUsageIntervalInputEvent",
DriverWorkingTimeEsperContractNames.VEHICLE_USAGE_INTERVAL_INPUT_EVENT_TYPE,
vehicleUsageIntervalInputDefinition()
);
configuration.getCommon().addEventType(
"TachographSupportGeoEvidenceInputEvent",
DriverWorkingTimeEsperContractNames.SUPPORT_EVIDENCE_INPUT_EVENT_TYPE,
supportGeoEvidenceInputDefinition()
);
return configuration;
@ -972,7 +979,8 @@ public class DriverWorkingTimeReusableProjectionBuilder {
long runtimeInitMs,
long deployMs,
long listenerRegistrationMs,
long runtimeResetMs,
long runtimeResetBeforeMs,
long runtimeResetAfterMs,
long sendSupportGeoMs,
long sendVehicleUsageMs,
long sendActivityMs,
@ -999,6 +1007,9 @@ public class DriverWorkingTimeReusableProjectionBuilder {
if (runtime == null) {
return 0L;
}
if (!runtime.cleanForReuse()) {
return runtime.destroy();
}
synchronized (this) {
if (idleRuntimes.size() < MAX_IDLE_RUNTIMES_PER_DEFINITION) {
runtime.poolHit(false);
@ -1018,6 +1029,7 @@ public class DriverWorkingTimeReusableProjectionBuilder {
private volatile long listenerRegistrationMs;
private volatile List<EPCompiled> cleanupQueries = List.of();
private volatile boolean poolHit;
private volatile boolean cleanForReuse = true;
private ExecutionListeners currentExecutionListeners;
private ReusableProjectionRuntime(
@ -1036,24 +1048,48 @@ public class DriverWorkingTimeReusableProjectionBuilder {
Map<String, Consumer<EventBean[]>> listeners,
Consumer<DerivedProjectionEventSender> sender
) {
cleanForReuse = false;
currentExecutionListeners = new ExecutionListeners(listeners);
long runtimeResetStartedAtNanos = System.nanoTime();
for (EPCompiled cleanupQuery : cleanupQueries) {
runtime.getFireAndForgetService().executeQuery(cleanupQuery);
}
long runtimeResetMs = elapsedMillisStatic(runtimeResetStartedAtNanos);
try {
long runtimeResetBeforeMs = 0L;
long runtimeResetAfterMs = 0L;
DerivedProjectionEventSender timedSender = new DerivedProjectionEventSender(runtime);
Throwable executionFailure = null;
try {
runtimeResetBeforeMs = resetExecutionState();
sender.accept(timedSender);
} catch (RuntimeException | Error failure) {
executionFailure = failure;
throw failure;
} finally {
// Cleanup must not feed delete/old-data callbacks into result collectors.
currentExecutionListeners = null;
try {
runtimeResetAfterMs = resetExecutionState();
cleanForReuse = true;
} catch (RuntimeException | Error cleanupFailure) {
cleanForReuse = false;
if (executionFailure != null) {
executionFailure.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
}
}
return new ReusableProjectionRuntimeExecution(
runtimeResetMs,
runtimeResetBeforeMs,
runtimeResetAfterMs,
timedSender.sendSupportGeoMs(),
timedSender.sendVehicleUsageMs(),
timedSender.sendActivityMs()
);
} finally {
currentExecutionListeners = null;
}
private long resetExecutionState() {
long runtimeResetStartedAtNanos = System.nanoTime();
for (EPCompiled cleanupQuery : cleanupQueries) {
runtime.getFireAndForgetService().executeQuery(cleanupQuery);
}
return elapsedMillisStatic(runtimeResetStartedAtNanos);
}
private void onStatementEvents(String statementName, EventBean[] newData) {
@ -1097,6 +1133,10 @@ public class DriverWorkingTimeReusableProjectionBuilder {
return poolHit;
}
private boolean cleanForReuse() {
return cleanForReuse;
}
private long runtimeInitMs() {
return poolHit ? 0L : runtimeInitMs;
}
@ -1119,7 +1159,8 @@ public class DriverWorkingTimeReusableProjectionBuilder {
}
private record ReusableProjectionRuntimeExecution(
long runtimeResetMs,
long runtimeResetBeforeMs,
long runtimeResetAfterMs,
long sendSupportGeoMs,
long sendVehicleUsageMs,
long sendActivityMs
@ -1139,19 +1180,19 @@ public class DriverWorkingTimeReusableProjectionBuilder {
private void sendSupportGeoEvent(Map<String, Object> event) {
long startedAtNanos = System.nanoTime();
delegate.getEventService().sendEventMap(event, "TachographSupportGeoEvidenceInputEvent");
delegate.getEventService().sendEventMap(event, DriverWorkingTimeEsperContractNames.SUPPORT_EVIDENCE_INPUT_EVENT_TYPE);
sendSupportGeoMs += elapsedMillisStatic(startedAtNanos);
}
private void sendVehicleUsageEvent(Map<String, Object> event) {
long startedAtNanos = System.nanoTime();
delegate.getEventService().sendEventMap(event, "TachographVehicleUsageIntervalInputEvent");
delegate.getEventService().sendEventMap(event, DriverWorkingTimeEsperContractNames.VEHICLE_USAGE_INTERVAL_INPUT_EVENT_TYPE);
sendVehicleUsageMs += elapsedMillisStatic(startedAtNanos);
}
private void sendActivityEvent(Map<String, Object> event) {
long startedAtNanos = System.nanoTime();
delegate.getEventService().sendEventMap(event, "TachographActivityIntervalInputEvent");
delegate.getEventService().sendEventMap(event, DriverWorkingTimeEsperContractNames.ACTIVITY_INTERVAL_INPUT_EVENT_TYPE);
sendActivityMs += elapsedMillisStatic(startedAtNanos);
}

View File

@ -11,6 +11,8 @@ public record RuntimeEventDescriptor(
String eventIdentityKey,
String eventKey,
RuntimeEventSourceProfile sourceProfile,
RuntimeTachographEvidenceSourceRole evidenceSourceRole,
RuntimeTachographRepresentation representation,
String compatibleActivityKey,
String compatibleSupportEvidenceKey,
boolean driverActivityPoint,
@ -37,6 +39,18 @@ public record RuntimeEventDescriptor(
return sourceProfile == null ? null : sourceProfile.extractionCode();
}
public RuntimeTachographEvidenceSourceRole evidenceSourceRole() {
return evidenceSourceRole == null
? RuntimeTachographEvidenceSourceRole.UNKNOWN
: evidenceSourceRole;
}
public RuntimeTachographRepresentation representation() {
return representation == null
? RuntimeTachographRepresentation.UNKNOWN
: representation;
}
public String keyFor(String equivalenceType) {
return switch (equivalenceType) {
case RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY -> eventKey;

View File

@ -3,20 +3,30 @@ package at.procon.eventhub.processing.eventprocessing.mixing;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.GeoPointDto;
import at.procon.eventhub.processing.support.RuntimeEntityReferenceResolver;
import at.procon.eventhub.processing.support.RuntimeEventIdentityResolver;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class RuntimeEventDescriptorFactory {
private final RuntimeTachographEventSemantics tachographSemantics;
@Autowired
public RuntimeEventDescriptorFactory(RuntimeTachographEventSemantics tachographSemantics) {
this.tachographSemantics = tachographSemantics;
}
/** Compatibility constructor used by unit tests. */
public RuntimeEventDescriptorFactory() {
this(new RuntimeTachographEventSemantics());
}
public List<RuntimeEventDescriptor> describeSorted(List<EventHubEventDto> events) {
return sort(events).stream()
.map(this::describe)
@ -30,6 +40,8 @@ public class RuntimeEventDescriptorFactory {
eventIdentityKey(event),
RuntimeEventIdentityResolver.canonicalEventKey(event),
profile,
profile.evidenceSourceRole(),
profile.representation(),
compatibleActivityKey(event),
compatibleSupportEvidenceKey(event),
isDriverActivityPoint(event),
@ -64,26 +76,7 @@ public class RuntimeEventDescriptorFactory {
}
public RuntimeEventSourceProfile sourceProfile(EventHubEventDto event) {
JsonNode raw = rawPayload(event);
String sourceKind = firstNonBlank(text(raw, "sourceKind"), sourceKind(event));
String extractionCode = firstNonBlank(
text(raw, "extractionCode"),
fileSessionExtractionCode(event, sourceKind),
extractionCodeFromExternalSourceEventId(event)
);
String sourceSystem = firstNonBlank(
text(raw, "sourceSystem"),
sourceProvider(event),
sourceSystemFromExternalSourceEventId(event)
);
if (sourceSystem == null && (extractionCode != null || isTachographFileSessionEvent(event))) {
sourceSystem = "TACHOGRAPH";
}
return new RuntimeEventSourceProfile(
normalizeUpper(sourceSystem),
normalizeUpper(sourceKind),
normalizeUpper(extractionCode)
);
return tachographSemantics.sourceProfile(event);
}
public String eventIdentityKey(EventHubEventDto event) {
@ -91,8 +84,8 @@ public class RuntimeEventDescriptorFactory {
return "<null>";
}
return firstNonBlank(
event.externalSourceEventId(),
event.eventId() == null ? null : event.eventId().toString(),
event.externalSourceEventId(),
RuntimeEventIdentityResolver.canonicalEventKey(event)
);
}
@ -106,186 +99,32 @@ public class RuntimeEventDescriptorFactory {
.thenComparing(EventHubEventDto::externalSourceEventId, Comparator.nullsLast(String::compareTo));
}
private String fileSessionExtractionCode(EventHubEventDto event, String sourceKind) {
if (!isTachographFileSessionEvent(event)) {
return null;
}
String normalizedSourceKind = normalizeUpper(sourceKind);
if (normalizedSourceKind == null) {
return null;
}
if (event != null && event.eventDomain() == EventDomain.DRIVER_ACTIVITY) {
return switch (normalizedSourceKind) {
case "DRIVER_CARD" -> "CARD_ACTIVITY";
case "VEHICLE_UNIT" -> "VU_ACTIVITY";
default -> null;
};
}
if (event != null && event.eventDomain() == EventDomain.DRIVER_CARD) {
return switch (normalizedSourceKind) {
case "DRIVER_CARD" -> "CARD_VEHICLES_USED";
case "VEHICLE_UNIT" -> "IW_CYCLE";
default -> null;
};
}
if (event == null || event.eventDomain() == null) {
return null;
}
String prefix = switch (normalizedSourceKind) {
case "DRIVER_CARD" -> "CARD";
case "VEHICLE_UNIT" -> "VU";
default -> null;
};
if (prefix == null) {
return null;
}
return switch (event.eventDomain()) {
case POSITION -> prefix + "_POSITION";
case PLACE -> prefix + "_PLACE";
case BORDER_CROSSING -> prefix + "_BORDER_CROSSING";
case LOAD_UNLOAD -> prefix + "_LOAD_UNLOAD";
case SPECIFIC_CONDITION -> prefix + "_SPECIFIC_CONDITION";
case SPEEDING -> Objects.equals("VU", prefix) ? "SPEEDING_EVENTS" : null;
default -> null;
};
}
private boolean isTachographFileSessionEvent(EventHubEventDto event) {
if (event == null) {
return false;
}
String packageKind = event.sourcePackageRef() == null ? null : normalizeUpper(event.sourcePackageRef().packageKind());
if (Objects.equals("TACHOGRAPH_FILE_SESSION", packageKind)
|| Objects.equals("COMPOSITE_TACHOGRAPH_FILE_SESSION", packageKind)) {
return true;
}
String provider = sourceProvider(event);
if (Objects.equals("TACHOGRAPH_FILE_SESSION", normalizeUpper(provider))
|| Objects.equals("COMPOSITE_TACHOGRAPH_FILE_SESSION", normalizeUpper(provider))) {
return true;
}
String sourceKey = event.packageInfo() == null || event.packageInfo().eventSource() == null
? null
: normalizeUpper(event.packageInfo().eventSource().sourceKey());
if (Objects.equals("TACHOGRAPH_FILE_SESSION", sourceKey)
|| Objects.equals("COMPOSITE_TACHOGRAPH_FILE_SESSION", sourceKey)) {
return true;
}
String externalId = event.externalSourceEventId();
return externalId != null
&& (externalId.startsWith("TACHOGRAPH_FILE_SESSION:")
|| externalId.startsWith("COMPOSITE_TACHOGRAPH_FILE_SESSION:"));
}
private String compatibleActivityKey(EventHubEventDto event) {
JsonNode raw = rawPayload(event);
return String.join("|",
"ACTIVITY_COMPATIBLE",
nullToEmpty(event == null || event.packageInfo() == null ? null : event.packageInfo().tenantKey()),
nullToEmpty(RuntimeEntityReferenceResolver.driverKey(event)),
nullToEmpty(event == null || event.eventDomain() == null ? null : event.eventDomain().name()),
nullToEmpty(event == null || event.eventType() == null ? null : event.eventType().name()),
nullToEmpty(event == null || event.lifecycle() == null ? null : event.lifecycle().name()),
normalizeTime(event == null ? null : event.occurredAt()),
nullToEmpty(RuntimeEntityReferenceResolver.registrationKey(event)),
nullToEmpty(firstNonBlank(text(raw, "startedAt"), text(raw, "intervalStartedAt"))),
nullToEmpty(firstNonBlank(text(raw, "endedAt"), text(raw, "intervalEndedAt"))),
nullToEmpty(firstNonBlank(text(raw, "slot"), text(raw, "cardSlot"))),
nullToEmpty(text(raw, "cardStatus")),
nullToEmpty(text(raw, "drivingStatus"))
nullToEmpty(RuntimeEntityReferenceResolver.registrationKey(event))
);
}
private String compatibleSupportEvidenceKey(EventHubEventDto event) {
JsonNode raw = rawPayload(event);
GeoPointDto position = event == null ? null : event.position();
return String.join("|",
"SUPPORT_COMPATIBLE",
nullToEmpty(event == null || event.packageInfo() == null ? null : event.packageInfo().tenantKey()),
nullToEmpty(RuntimeEntityReferenceResolver.driverKey(event)),
nullToEmpty(event == null || event.eventDomain() == null ? null : event.eventDomain().name()),
nullToEmpty(event == null || event.eventType() == null ? null : event.eventType().name()),
nullToEmpty(semanticSupportLifecycle(event)),
normalizeTime(event == null ? null : event.occurredAt()),
nullToEmpty(RuntimeEntityReferenceResolver.registrationKey(event)),
nullToEmpty(firstNonBlank(text(raw, "latitude"), position == null || position.latitude() == null ? null : position.latitude().toPlainString())),
nullToEmpty(firstNonBlank(text(raw, "longitude"), position == null || position.longitude() == null ? null : position.longitude().toPlainString())),
nullToEmpty(firstNonBlank(text(raw, "odometerM"), event == null || event.odometerM() == null ? null : String.valueOf(event.odometerM()))),
nullToEmpty(firstNonBlank(text(raw, "country"), detailText(event, "country"))),
nullToEmpty(firstNonBlank(text(raw, "region"), detailText(event, "region"))),
nullToEmpty(firstNonBlank(text(raw, "countryFrom"), detailText(event, "countryFrom"))),
nullToEmpty(firstNonBlank(text(raw, "countryTo"), detailText(event, "countryTo"))),
nullToEmpty(firstNonBlank(text(raw, "operation"), detailText(event, "operation")))
nullToEmpty(RuntimeEntityReferenceResolver.registrationKey(event))
);
}
private String semanticSupportLifecycle(EventHubEventDto event) {
if (event == null || event.lifecycle() == null) {
return null;
}
if (event.eventDomain() == EventDomain.PLACE
&& (event.lifecycle() == EventLifecycle.START || event.lifecycle() == EventLifecycle.BEGIN)) {
return EventLifecycle.BEGIN.name();
}
return event.lifecycle().name();
}
private JsonNode rawPayload(EventHubEventDto event) {
return RuntimeEntityReferenceResolver.rawPayload(event);
}
private String sourceKind(EventHubEventDto event) {
return event == null || event.packageInfo() == null || event.packageInfo().eventSource() == null
? null
: event.packageInfo().eventSource().sourceKind();
}
private String sourceProvider(EventHubEventDto event) {
return event == null || event.packageInfo() == null || event.packageInfo().eventSource() == null
? null
: event.packageInfo().eventSource().providerKey();
}
private String sourceSystemFromExternalSourceEventId(EventHubEventDto event) {
String externalId = event == null ? null : event.externalSourceEventId();
if (externalId == null || externalId.isBlank()) {
return null;
}
String[] parts = externalId.split(":");
return parts.length >= 1 ? parts[0] : null;
}
private String extractionCodeFromExternalSourceEventId(EventHubEventDto event) {
String externalId = event == null ? null : event.externalSourceEventId();
if (externalId == null || externalId.isBlank()) {
return null;
}
String[] parts = externalId.split(":");
return parts.length >= 2 ? parts[1] : null;
}
private String detailText(EventHubEventDto event, String field) {
if (event == null || event.eventDetails() == null || event.eventDetails().attributes() == null || field == null) {
return null;
}
JsonNode value = event.eventDetails().attributes().get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText(null);
return text == null || text.isBlank() ? null : text.trim();
}
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();
return tachographSemantics.semanticLifecycle(event);
}
private String firstNonBlank(String... values) {
@ -300,10 +139,6 @@ public class RuntimeEventDescriptorFactory {
return null;
}
private String normalizeUpper(String value) {
return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT);
}
private String nullToEmpty(Object value) {
return value == null ? "" : String.valueOf(value);
}

View File

@ -0,0 +1,267 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.GeoPointDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import at.procon.eventhub.processing.support.RuntimeEntityReferenceResolver;
import at.procon.eventhub.reference.TachographNationRegistry;
import com.fasterxml.jackson.databind.JsonNode;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Objects;
import org.springframework.stereotype.Component;
/**
* Performs semantic compatibility checks after broad event candidates have been grouped.
*
* <p>File-session and database representations of the same tachograph fact can differ in
* serialization details such as decimal scale, nation representation, default region values,
* optional vehicle identity and interval metadata. Those representation details must not be part
* of the candidate key, but meaningful conflicts must still prevent fusion.</p>
*/
@Component
public class RuntimeEventEvidenceCompatibilityMatcher {
private static final BigDecimal GEO_TOLERANCE = new BigDecimal("0.000000001");
public boolean compatible(
RuntimeEventMixingRule rule,
RuntimeEventDescriptor primary,
RuntimeEventDescriptor secondary
) {
if (rule == null || primary == null || secondary == null) {
return false;
}
if (rule.requireSameSourceRole()
&& primary.evidenceSourceRole() != secondary.evidenceSourceRole()) {
return false;
}
return switch (rule.equivalenceType()) {
case RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY -> true;
case RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY ->
activityCompatible(primary.event(), secondary.event());
case RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_SUPPORT_KEY ->
supportCompatible(primary.event(), secondary.event());
default -> false;
};
}
private boolean activityCompatible(EventHubEventDto left, EventHubEventDto right) {
return tenantCompatible(left, right)
&& registrationCompatible(left, right)
&& vehicleIdentityCompatible(left, right)
&& optionalTokenCompatible(activitySlot(left), activitySlot(right));
}
private boolean supportCompatible(EventHubEventDto left, EventHubEventDto right) {
return tenantCompatible(left, right)
&& registrationCompatible(left, right)
&& vehicleIdentityCompatible(left, right)
&& coordinatesCompatible(left, right)
&& odometerCompatible(left, right)
&& nationCompatible(detailValue(left, "country"), detailValue(right, "country"))
&& regionCompatible(detailValue(left, "region"), detailValue(right, "region"))
&& nationCompatible(detailValue(left, "countryFrom"), detailValue(right, "countryFrom"))
&& nationCompatible(detailValue(left, "countryTo"), detailValue(right, "countryTo"))
&& optionalTokenCompatible(detailValue(left, "operation"), detailValue(right, "operation"));
}
private boolean tenantCompatible(EventHubEventDto left, EventHubEventDto right) {
return optionalTokenCompatible(normalizedTenant(left), normalizedTenant(right));
}
private String normalizedTenant(EventHubEventDto event) {
String value = event == null || event.packageInfo() == null ? null : event.packageInfo().tenantKey();
String normalized = normalizeToken(value);
return Objects.equals("DEFAULT", normalized) ? null : normalized;
}
private boolean registrationCompatible(EventHubEventDto left, EventHubEventDto right) {
return optionalTokenCompatible(normalizedRegistration(left), normalizedRegistration(right));
}
private String normalizedRegistration(EventHubEventDto event) {
VehicleRefDto vehicleRef = event == null ? null : event.vehicleRef();
VehicleRegistrationRefDto registration = vehicleRef == null ? null : vehicleRef.vehicleRegistration();
if (registration != null && registration.hasValue()) {
String nation = normalizedNation(registration.nation(), registration.nationNumericCode());
String number = normalizeIdentifier(registration.number());
return nullToEmpty(nation) + ":" + nullToEmpty(number);
}
String key = RuntimeEntityReferenceResolver.registrationKey(event);
if (key == null || key.isBlank()) {
return null;
}
int separator = key.indexOf(':');
if (separator < 0) {
return normalizeIdentifier(key);
}
String nation = normalizedNation(key.substring(0, separator), null);
String number = normalizeIdentifier(key.substring(separator + 1));
return nullToEmpty(nation) + ":" + nullToEmpty(number);
}
private boolean vehicleIdentityCompatible(EventHubEventDto left, EventHubEventDto right) {
String leftVin = normalizedVin(left);
String rightVin = normalizedVin(right);
return optionalTokenCompatible(leftVin, rightVin);
}
private String normalizedVin(EventHubEventDto event) {
String vehicleKey = RuntimeEntityReferenceResolver.vehicleKey(event);
if (vehicleKey != null) {
return normalizeIdentifier(vehicleKey);
}
VehicleRefDto vehicleRef = event == null ? null : event.vehicleRef();
return vehicleRef == null ? null : normalizeIdentifier(vehicleRef.vin());
}
private boolean coordinatesCompatible(EventHubEventDto left, EventHubEventDto right) {
BigDecimal leftLatitude = coordinate(left, true);
BigDecimal rightLatitude = coordinate(right, true);
BigDecimal leftLongitude = coordinate(left, false);
BigDecimal rightLongitude = coordinate(right, false);
return optionalDecimalCompatible(leftLatitude, rightLatitude, GEO_TOLERANCE)
&& optionalDecimalCompatible(leftLongitude, rightLongitude, GEO_TOLERANCE);
}
private BigDecimal coordinate(EventHubEventDto event, boolean latitude) {
GeoPointDto position = event == null ? null : event.position();
BigDecimal value = position == null ? null : latitude ? position.latitude() : position.longitude();
if (value != null) {
return value;
}
return decimal(rawValue(event, latitude ? "latitude" : "longitude"));
}
private boolean odometerCompatible(EventHubEventDto left, EventHubEventDto right) {
BigDecimal leftValue = odometerM(left);
BigDecimal rightValue = odometerM(right);
return optionalDecimalCompatible(leftValue, rightValue, BigDecimal.ZERO);
}
private BigDecimal odometerM(EventHubEventDto event) {
if (event != null && event.odometerM() != null) {
return BigDecimal.valueOf(event.odometerM());
}
BigDecimal meters = decimal(rawValue(event, "odometerM"));
if (meters != null) {
return meters;
}
BigDecimal kilometres = decimal(rawValue(event, "odometerKm"));
return kilometres == null ? null : kilometres.multiply(BigDecimal.valueOf(1000));
}
private String activitySlot(EventHubEventDto event) {
return firstNonBlank(
rawValue(event, "slot"),
rawValue(event, "cardSlot"),
detailAttribute(event, "cardSlot")
);
}
private String detailValue(EventHubEventDto event, String field) {
return firstNonBlank(rawValue(event, field), detailAttribute(event, field));
}
private String rawValue(EventHubEventDto event, String field) {
return text(RuntimeEntityReferenceResolver.rawPayload(event), field);
}
private String detailAttribute(EventHubEventDto event, String field) {
JsonNode attributes = event == null || event.eventDetails() == null
? null
: event.eventDetails().attributes();
return text(attributes, field);
}
private boolean nationCompatible(String left, String right) {
String normalizedLeft = normalizedNation(left, null);
String normalizedRight = normalizedNation(right, null);
return optionalTokenCompatible(normalizedLeft, normalizedRight);
}
private String normalizedNation(String nation, Integer numericCode) {
TachographNationRegistry.NationResolution resolution =
TachographNationRegistry.resolve(nation, numericCode);
if (resolution.numericCode() != null) {
return String.valueOf(resolution.numericCode());
}
return normalizeToken(resolution.legacyNation());
}
private boolean regionCompatible(String left, String right) {
return optionalTokenCompatible(normalizedRegion(left), normalizedRegion(right));
}
private String normalizedRegion(String value) {
String normalized = normalizeToken(value);
return normalized == null || Objects.equals("0", normalized) ? null : normalized;
}
private boolean optionalTokenCompatible(String left, String right) {
String normalizedLeft = normalizeToken(left);
String normalizedRight = normalizeToken(right);
return normalizedLeft == null || normalizedRight == null || normalizedLeft.equals(normalizedRight);
}
private boolean optionalDecimalCompatible(BigDecimal left, BigDecimal right, BigDecimal tolerance) {
if (left == null || right == null) {
return true;
}
return left.subtract(right).abs().compareTo(tolerance) <= 0;
}
private BigDecimal decimal(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return new BigDecimal(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
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();
}
private String normalizeIdentifier(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "");
return normalized.isBlank() ? null : normalized;
}
private String normalizeToken(String value) {
return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT);
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value.trim();
}
}
return null;
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
}

View File

@ -0,0 +1,17 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
/** Classification and rule-application counters for one event-mixing execution. */
public record RuntimeEventMixingDiagnostics(
int describedEventCount,
int tachographEventCount,
int driverCardSourceRoleCount,
int vehicleUnitSourceRoleCount,
int unknownSourceRoleCount,
int databaseRepresentationCount,
int fileSessionRepresentationCount,
int unknownRepresentationCount,
int candidateGroupCount,
int compatibilityRejectedCount,
int suppressedEventCount
) {
}

View File

@ -14,6 +14,11 @@ public record RuntimeEventMixingRule(
Set<EventLifecycle> lifecycles,
Set<String> primaryExtractionCodes,
Set<String> secondaryExtractionCodes,
Set<RuntimeTachographEvidenceSourceRole> primarySourceRoles,
Set<RuntimeTachographEvidenceSourceRole> secondarySourceRoles,
Set<RuntimeTachographRepresentation> primaryRepresentations,
Set<RuntimeTachographRepresentation> secondaryRepresentations,
boolean requireSameSourceRole,
RuntimeResolvedEventRole primaryRole,
RuntimeResolvedEventRole secondaryRole,
String decision,
@ -29,6 +34,10 @@ public record RuntimeEventMixingRule(
lifecycles = lifecycles == null ? Set.of() : Set.copyOf(lifecycles);
primaryExtractionCodes = normalize(primaryExtractionCodes);
secondaryExtractionCodes = normalize(secondaryExtractionCodes);
primarySourceRoles = primarySourceRoles == null ? Set.of() : Set.copyOf(primarySourceRoles);
secondarySourceRoles = secondarySourceRoles == null ? Set.of() : Set.copyOf(secondarySourceRoles);
primaryRepresentations = primaryRepresentations == null ? Set.of() : Set.copyOf(primaryRepresentations);
secondaryRepresentations = secondaryRepresentations == null ? Set.of() : Set.copyOf(secondaryRepresentations);
}
public boolean matches(RuntimeEventDescriptor descriptor) {
@ -53,16 +62,55 @@ public record RuntimeEventMixingRule(
if (channel == RuntimeEventMixingChannel.SUPPORT_EVIDENCE && !descriptor.supportEvidenceCandidate()) {
return false;
}
String extractionCode = descriptor.extractionCode();
return primaryExtractionCodes.contains(extractionCode) || secondaryExtractionCodes.contains(extractionCode);
return isPrimary(descriptor) || isSecondary(descriptor);
}
public boolean isPrimary(RuntimeEventDescriptor descriptor) {
return descriptor != null && primaryExtractionCodes.contains(descriptor.extractionCode());
return sideMatches(
descriptor,
primaryExtractionCodes,
primarySourceRoles,
primaryRepresentations
);
}
public boolean isSecondary(RuntimeEventDescriptor descriptor) {
return descriptor != null && secondaryExtractionCodes.contains(descriptor.extractionCode());
return sideMatches(
descriptor,
secondaryExtractionCodes,
secondarySourceRoles,
secondaryRepresentations
);
}
private boolean sideMatches(
RuntimeEventDescriptor descriptor,
Set<String> extractionCodes,
Set<RuntimeTachographEvidenceSourceRole> sourceRoles,
Set<RuntimeTachographRepresentation> representations
) {
if (descriptor == null) {
return false;
}
if (!representations.isEmpty() && !representations.contains(descriptor.representation())) {
return false;
}
if (!sourceRoles.isEmpty()) {
RuntimeTachographEvidenceSourceRole role = descriptor.evidenceSourceRole();
if (role != RuntimeTachographEvidenceSourceRole.UNKNOWN) {
return sourceRoles.contains(role);
}
// Extraction code is a fallback only when the semantic source role could not be
// resolved. A conflicting explicit role must never make one event both primary and
// secondary.
return !extractionCodes.isEmpty()
&& extractionCodes.contains(normalize(descriptor.extractionCode()));
}
if (!extractionCodes.isEmpty()) {
return extractionCodes.contains(normalize(descriptor.extractionCode()));
}
return true;
}
private static Set<String> normalize(Set<String> values) {
@ -71,7 +119,13 @@ public record RuntimeEventMixingRule(
}
return values.stream()
.filter(value -> value != null && !value.isBlank())
.map(value -> value.trim().toUpperCase(java.util.Locale.ROOT))
.map(RuntimeEventMixingRule::normalize)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
private static String normalize(String value) {
return value == null || value.isBlank()
? null
: value.trim().toUpperCase(java.util.Locale.ROOT);
}
}

View File

@ -41,6 +41,14 @@ public class RuntimeEventMixingRuleRegistry {
EventLifecycle.OUT_EU
);
private static final Set<EventType> ACTIVITY_EVENT_TYPES = Set.of(
EventType.DRIVE,
EventType.BREAK_REST,
EventType.AVAILABILITY,
EventType.WORK,
EventType.UNKNOWN_ACTIVITY
);
private static final Set<String> CARD_SUPPORT_EXTRACTION_CODES = Set.of(
"CARD_POSITION",
"CARD_PLACE",
@ -57,11 +65,18 @@ public class RuntimeEventMixingRuleRegistry {
"VU_SPECIFIC_CONDITION"
);
private static final Set<RuntimeTachographEvidenceSourceRole> BOTH_TACHOGRAPH_ROLES = Set.of(
RuntimeTachographEvidenceSourceRole.DRIVER_CARD,
RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT
);
public List<RuntimeEventMixingRule> rulesForMode(String mode) {
if (RuntimeEventMixingService.MODE_OFF.equals(mode)) {
return List.of();
}
return List.of(
tachographDbFileSessionSameRoleActivityCompatibleKey(),
tachographDbFileSessionSameRoleSupportCompatibleKey(),
tachographCardVuActivityExactEventKey(),
tachographCardVuSupportExactEventKey(),
tachographCardVuActivityCompatibleKey(),
@ -69,16 +84,65 @@ public class RuntimeEventMixingRuleRegistry {
);
}
private RuntimeEventMixingRule tachographDbFileSessionSameRoleActivityCompatibleKey() {
return new RuntimeEventMixingRule(
RuntimeEventMixingService.RULE_TACHOGRAPH_DB_FILE_SESSION_ACTIVITY_SAME_ROLE,
RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY,
Set.of(EventDomain.DRIVER_ACTIVITY),
ACTIVITY_EVENT_TYPES,
Set.of(EventLifecycle.START, EventLifecycle.END),
Set.of(),
Set.of(),
BOTH_TACHOGRAPH_ROLES,
BOTH_TACHOGRAPH_ROLES,
Set.of(RuntimeTachographRepresentation.DATABASE),
Set.of(RuntimeTachographRepresentation.FILE_SESSION),
true,
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"CROSS_REPRESENTATION_DUPLICATE_SUPPRESSED",
"The tachograph database and file-session representations describe the same activity point from the same source role. The database representation is retained and the file-session representation is suppressed."
);
}
private RuntimeEventMixingRule tachographDbFileSessionSameRoleSupportCompatibleKey() {
return new RuntimeEventMixingRule(
RuntimeEventMixingService.RULE_TACHOGRAPH_DB_FILE_SESSION_SUPPORT_SAME_ROLE,
RuntimeEventMixingChannel.SUPPORT_EVIDENCE,
RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_SUPPORT_KEY,
SUPPORT_EVENT_DOMAINS,
SUPPORT_EVENT_TYPES,
SUPPORT_EVENT_LIFECYCLES,
Set.of(),
Set.of(),
BOTH_TACHOGRAPH_ROLES,
BOTH_TACHOGRAPH_ROLES,
Set.of(RuntimeTachographRepresentation.DATABASE),
Set.of(RuntimeTachographRepresentation.FILE_SESSION),
true,
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"CROSS_REPRESENTATION_DUPLICATE_SUPPRESSED",
"The tachograph database and file-session representations describe the same support event from the same source role. The database representation is retained and the file-session representation is suppressed."
);
}
private RuntimeEventMixingRule tachographCardVuActivityExactEventKey() {
return new RuntimeEventMixingRule(
RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_ACTIVITY_SAME_EVENT_KEY,
RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY,
Set.of(EventDomain.DRIVER_ACTIVITY),
Set.of(EventType.DRIVE, EventType.BREAK_REST, EventType.AVAILABILITY, EventType.WORK, EventType.UNKNOWN_ACTIVITY),
ACTIVITY_EVENT_TYPES,
Set.of(EventLifecycle.START, EventLifecycle.END),
Set.of("CARD_ACTIVITY"),
Set.of("VU_ACTIVITY"),
Set.of(RuntimeTachographEvidenceSourceRole.DRIVER_CARD),
Set.of(RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT),
Set.of(),
Set.of(),
false,
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"FUSED_PRIMARY_SELECTED",
@ -92,10 +156,15 @@ public class RuntimeEventMixingRuleRegistry {
RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY,
Set.of(EventDomain.DRIVER_ACTIVITY),
Set.of(EventType.DRIVE, EventType.BREAK_REST, EventType.AVAILABILITY, EventType.WORK, EventType.UNKNOWN_ACTIVITY),
ACTIVITY_EVENT_TYPES,
Set.of(EventLifecycle.START, EventLifecycle.END),
Set.of("CARD_ACTIVITY"),
Set.of("VU_ACTIVITY"),
Set.of(RuntimeTachographEvidenceSourceRole.DRIVER_CARD),
Set.of(RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT),
Set.of(),
Set.of(),
false,
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"FUSED_PRIMARY_SELECTED",
@ -113,6 +182,11 @@ public class RuntimeEventMixingRuleRegistry {
SUPPORT_EVENT_LIFECYCLES,
CARD_SUPPORT_EXTRACTION_CODES,
VU_SUPPORT_EXTRACTION_CODES,
Set.of(RuntimeTachographEvidenceSourceRole.DRIVER_CARD),
Set.of(RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT),
Set.of(),
Set.of(),
false,
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"FUSED_PRIMARY_SELECTED",
@ -130,6 +204,11 @@ public class RuntimeEventMixingRuleRegistry {
SUPPORT_EVENT_LIFECYCLES,
CARD_SUPPORT_EXTRACTION_CODES,
VU_SUPPORT_EXTRACTION_CODES,
Set.of(RuntimeTachographEvidenceSourceRole.DRIVER_CARD),
Set.of(RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT),
Set.of(),
Set.of(),
false,
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"FUSED_PRIMARY_SELECTED",

View File

@ -5,6 +5,7 @@ import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@ -29,22 +30,33 @@ public class RuntimeEventMixingService {
"tachograph.support.card-vu.same-event-key";
public static final String RULE_TACHOGRAPH_CARD_VU_SUPPORT_COMPATIBLE_KEY =
"tachograph.support.card-vu.compatible-support-key";
public static final String RULE_TACHOGRAPH_DB_FILE_SESSION_ACTIVITY_SAME_ROLE =
"tachograph.activity.db-file-session.same-source-role";
public static final String RULE_TACHOGRAPH_DB_FILE_SESSION_SUPPORT_SAME_ROLE =
"tachograph.support.db-file-session.same-source-role";
private final RuntimeEventDescriptorFactory descriptorFactory;
private final RuntimeEventMixingRuleRegistry ruleRegistry;
private final RuntimeEventEvidenceCompatibilityMatcher compatibilityMatcher;
@Autowired
public RuntimeEventMixingService(
RuntimeEventDescriptorFactory descriptorFactory,
RuntimeEventMixingRuleRegistry ruleRegistry
RuntimeEventMixingRuleRegistry ruleRegistry,
RuntimeEventEvidenceCompatibilityMatcher compatibilityMatcher
) {
this.descriptorFactory = descriptorFactory;
this.ruleRegistry = ruleRegistry;
this.compatibilityMatcher = compatibilityMatcher;
}
/** Compatibility constructor used by unit tests and local registries. */
public RuntimeEventMixingService() {
this(new RuntimeEventDescriptorFactory(), new RuntimeEventMixingRuleRegistry());
this(
new RuntimeEventDescriptorFactory(),
new RuntimeEventMixingRuleRegistry(),
new RuntimeEventEvidenceCompatibilityMatcher()
);
}
public RuntimeMixedEventBundle mix(List<EventHubEventDto> events, String requestedMode) {
@ -79,6 +91,7 @@ public class RuntimeEventMixingService {
.toList();
List<RuntimeResolvedEvent> resolvedEvents = buildResolvedEvents(state, rawEvents);
RuntimeEventMixingDiagnostics diagnostics = diagnostics(descriptors, state);
List<String> notes = new ArrayList<>();
notes.add("Runtime event mixing inspected " + rawEvents.size() + " event(s).");
notes.add("Runtime event mixing applied " + ruleRegistry.rulesForMode(mode).size() + " configured rule(s) in mode " + mode + ".");
@ -86,6 +99,12 @@ public class RuntimeEventMixingService {
+ " duplicate source event(s) from activity/support evidence channels.");
notes.add("Runtime event mixing keeps CARD_POSITION, CARD_PLACE, and CARD_BORDER_CROSSING as primary when matching VU support evidence describes the same semantic event.");
notes.add("Runtime event mixing kept all CARD_VEHICLES_USED and IW_CYCLE card-usage point events unchanged for vehicle-usage processing.");
notes.add("Runtime event mixing classified " + diagnostics.driverCardSourceRoleCount()
+ " DRIVER_CARD and " + diagnostics.vehicleUnitSourceRoleCount()
+ " VEHICLE_UNIT event(s); unknown source roles=" + diagnostics.unknownSourceRoleCount() + ".");
notes.add("Runtime event mixing classified " + diagnostics.databaseRepresentationCount()
+ " database and " + diagnostics.fileSessionRepresentationCount()
+ " file-session representation event(s).");
return new RuntimeMixedEventBundle(
rawEvents,
driverPartitionEvents,
@ -95,6 +114,7 @@ public class RuntimeEventMixingService {
state.suppressedEvents(),
resolvedEvents,
state.decisions(),
diagnostics,
notes,
state.warnings()
);
@ -110,6 +130,7 @@ public class RuntimeEventMixingService {
List.of(),
descriptors.stream().map(this::defaultResolvedEvent).toList(),
List.of(),
diagnostics(descriptors, new MixingState(descriptors)),
List.of(note),
List.of()
);
@ -149,23 +170,43 @@ public class RuntimeEventMixingService {
if (primaries.isEmpty() || secondaries.isEmpty()) {
return;
}
RuntimeEventDescriptor primary = primaries.getFirst();
List<RuntimeEventDescriptor> newlySuppressed = secondaries.stream()
.filter(descriptor -> !state.isSuppressed(descriptor))
.toList();
if (newlySuppressed.isEmpty()) {
return;
state.incrementCandidateGroupCount();
for (RuntimeEventDescriptor primary : primaries) {
List<RuntimeEventDescriptor> compatibleSecondaries = new ArrayList<>();
for (RuntimeEventDescriptor secondary : secondaries) {
if (state.isSuppressed(secondary)) {
continue;
}
if (compatibilityMatcher.compatible(rule, primary, secondary)) {
compatibleSecondaries.add(secondary);
} else {
state.incrementCompatibilityRejectedCount();
}
}
if (compatibleSecondaries.isEmpty()) {
continue;
}
fusePrimaryWithSecondaries(state, rule, eventKey, primary, compatibleSecondaries);
}
}
private void fusePrimaryWithSecondaries(
MixingState state,
RuntimeEventMixingRule rule,
String eventKey,
RuntimeEventDescriptor primary,
List<RuntimeEventDescriptor> secondaries
) {
EventHubEventDto enrichedPrimary = enrichPrimaryVehicleRef(
primary.event(),
newlySuppressed.stream().map(RuntimeEventDescriptor::event).toList()
secondaries.stream().map(RuntimeEventDescriptor::event).toList()
);
if (enrichedPrimary != primary.event()) {
state.replace(primary, enrichedPrimary);
}
newlySuppressed.forEach(descriptor -> state.suppress(descriptor, rule, primary, eventKey));
state.markPrimary(primary, rule, eventKey, newlySuppressed);
secondaries.forEach(descriptor -> state.suppress(descriptor, rule, primary, eventKey));
state.markPrimary(primary, rule, eventKey, secondaries);
state.decisions().add(new RuntimeEventMixingDecisionDto(
rule.ruleId(),
rule.equivalenceType(),
@ -174,8 +215,8 @@ public class RuntimeEventMixingService {
rule.channel().name(),
primary.event().externalSourceEventId(),
primary.extractionCode(),
newlySuppressed.stream().map(descriptor -> descriptor.event().externalSourceEventId()).toList(),
newlySuppressed.stream().map(RuntimeEventDescriptor::extractionCode).toList(),
secondaries.stream().map(descriptor -> descriptor.event().externalSourceEventId()).toList(),
secondaries.stream().map(RuntimeEventDescriptor::extractionCode).toList(),
primary.event().occurredAt(),
primary.eventDomain() == null ? null : primary.eventDomain().name(),
primary.eventType() == null ? null : primary.eventType().name(),
@ -200,6 +241,54 @@ public class RuntimeEventMixingService {
return List.copyOf(resolved);
}
private RuntimeEventMixingDiagnostics diagnostics(
List<RuntimeEventDescriptor> descriptors,
MixingState state
) {
List<RuntimeEventDescriptor> safeDescriptors = descriptors == null ? List.of() : descriptors;
int tachographEventCount = (int) safeDescriptors.stream()
.filter(descriptor -> descriptor.sourceProfile() != null
&& descriptor.sourceProfile().isTachographRuntimeSource())
.count();
int driverCardCount = (int) safeDescriptors.stream()
.filter(descriptor -> descriptor.evidenceSourceRole()
== RuntimeTachographEvidenceSourceRole.DRIVER_CARD)
.count();
int vehicleUnitCount = (int) safeDescriptors.stream()
.filter(descriptor -> descriptor.evidenceSourceRole()
== RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT)
.count();
int unknownRoleCount = (int) safeDescriptors.stream()
.filter(descriptor -> descriptor.evidenceSourceRole()
== RuntimeTachographEvidenceSourceRole.UNKNOWN)
.count();
int databaseCount = (int) safeDescriptors.stream()
.filter(descriptor -> descriptor.representation()
== RuntimeTachographRepresentation.DATABASE)
.count();
int fileSessionCount = (int) safeDescriptors.stream()
.filter(descriptor -> descriptor.representation()
== RuntimeTachographRepresentation.FILE_SESSION)
.count();
int unknownRepresentationCount = (int) safeDescriptors.stream()
.filter(descriptor -> descriptor.representation()
== RuntimeTachographRepresentation.UNKNOWN)
.count();
return new RuntimeEventMixingDiagnostics(
safeDescriptors.size(),
tachographEventCount,
driverCardCount,
vehicleUnitCount,
unknownRoleCount,
databaseCount,
fileSessionCount,
unknownRepresentationCount,
state == null ? 0 : state.candidateGroupCount(),
state == null ? 0 : state.compatibilityRejectedCount(),
state == null ? 0 : state.suppressedEvents().size()
);
}
private RuntimeResolvedEvent defaultResolvedEvent(RuntimeEventDescriptor descriptor) {
RuntimeEventMixingChannel channel = defaultChannel(descriptor);
RuntimeResolvedEventRole role = switch (channel) {
@ -342,18 +431,22 @@ public class RuntimeEventMixingService {
private static final class MixingState {
private final List<RuntimeEventDescriptor> descriptors;
private final Map<String, RuntimeEventDescriptor> descriptorsByEventId = new LinkedHashMap<>();
private final Map<EventHubEventDto, RuntimeEventDescriptor> descriptorsByEvent = new IdentityHashMap<>();
private final Set<String> suppressedEventIds = new LinkedHashSet<>();
private final Map<String, EventHubEventDto> replacementsByEventId = new LinkedHashMap<>();
private final Map<String, RuntimeResolvedEvent> resolvedEventsByEventId = new LinkedHashMap<>();
private final List<EventHubEventDto> suppressedEvents = new ArrayList<>();
private final List<RuntimeEventMixingDecisionDto> decisions = new ArrayList<>();
private final List<String> warnings = new ArrayList<>();
private int candidateGroupCount;
private int compatibilityRejectedCount;
private MixingState(List<RuntimeEventDescriptor> descriptors) {
this.descriptors = descriptors == null ? List.of() : List.copyOf(descriptors);
for (RuntimeEventDescriptor descriptor : this.descriptors) {
descriptorsByEventId.put(descriptor.eventIdentityKey(), descriptor);
if (descriptor.event() != null) {
descriptorsByEvent.put(descriptor.event(), descriptor);
}
}
}
@ -382,9 +475,9 @@ public class RuntimeEventMixingService {
if (event == null) {
return null;
}
String externalId = event.externalSourceEventId();
if (externalId != null && descriptorsByEventId.containsKey(externalId)) {
return descriptorsByEventId.get(externalId);
RuntimeEventDescriptor direct = descriptorsByEvent.get(event);
if (direct != null) {
return direct;
}
return descriptors.stream()
.filter(descriptor -> descriptor.event() == event || Objects.equals(descriptor.event(), event))
@ -464,6 +557,22 @@ public class RuntimeEventMixingService {
return decisions;
}
private void incrementCandidateGroupCount() {
candidateGroupCount++;
}
private int candidateGroupCount() {
return candidateGroupCount;
}
private void incrementCompatibilityRejectedCount() {
compatibilityRejectedCount++;
}
private int compatibilityRejectedCount() {
return compatibilityRejectedCount;
}
private List<String> warnings() {
return warnings;
}

View File

@ -1,10 +1,36 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
public record RuntimeEventSourceProfile(
String sourceSystem,
String sourceKind,
String extractionCode,
RuntimeTachographEvidenceSourceRole evidenceSourceRole,
RuntimeTachographRepresentation representation
) {
/** Compatibility constructor retained for existing tests and direct callers. */
public RuntimeEventSourceProfile(
String sourceSystem,
String sourceKind,
String extractionCode
) {
this(
sourceSystem,
sourceKind,
extractionCode,
RuntimeTachographEvidenceSourceRole.UNKNOWN,
RuntimeTachographRepresentation.UNKNOWN
);
}
public RuntimeEventSourceProfile {
evidenceSourceRole = evidenceSourceRole == null
? RuntimeTachographEvidenceSourceRole.UNKNOWN
: evidenceSourceRole;
representation = representation == null
? RuntimeTachographRepresentation.UNKNOWN
: representation;
}
public boolean isTachographRuntimeSource() {
return switch (sourceSystem == null ? "" : sourceSystem) {
case "TACHOGRAPH", "TACHOGRAPH_FILE_SESSION", "COMPOSITE_TACHOGRAPH_FILE_SESSION" -> true;

View File

@ -12,6 +12,7 @@ public record RuntimeMixedEventBundle(
List<EventHubEventDto> suppressedEvents,
List<RuntimeResolvedEvent> resolvedEvents,
List<RuntimeEventMixingDecisionDto> eventMixingDecisions,
RuntimeEventMixingDiagnostics diagnostics,
List<String> notes,
List<String> warnings
) {
@ -24,6 +25,9 @@ public record RuntimeMixedEventBundle(
suppressedEvents = suppressedEvents == null ? List.of() : List.copyOf(suppressedEvents);
resolvedEvents = resolvedEvents == null ? List.of() : List.copyOf(resolvedEvents);
eventMixingDecisions = eventMixingDecisions == null ? List.of() : List.copyOf(eventMixingDecisions);
diagnostics = diagnostics == null
? new RuntimeEventMixingDiagnostics(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
: diagnostics;
notes = notes == null ? List.of() : List.copyOf(notes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}

View File

@ -0,0 +1,415 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.processing.support.RuntimeEntityReferenceResolver;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import org.springframework.stereotype.Component;
/**
* Canonical semantic view of tachograph runtime events.
*
* <p>The same tachograph package can be processed directly from a file session or loaded from
* the tachograph database after serialization. This component normalizes only representation
* differences relevant to runtime mixing and leaves the original event untouched.</p>
*/
@Component
public class RuntimeTachographEventSemantics {
private static final Set<String> TACHOGRAPH_SOURCE_SYSTEMS = Set.of(
"TACHOGRAPH",
"TACHOGRAPH_FILE_SESSION",
"COMPOSITE_TACHOGRAPH_FILE_SESSION"
);
private static final Set<String> KNOWN_EXTRACTION_CODES = Set.of(
"CARD_ACTIVITY",
"VU_ACTIVITY",
"CARD_VEHICLES_USED",
"IW_CYCLE",
"CARD_POSITION",
"VU_POSITION",
"CARD_PLACE",
"VU_PLACE",
"CARD_BORDER_CROSSING",
"VU_BORDER_CROSSING",
"CARD_LOAD_UNLOAD",
"VU_LOAD_UNLOAD",
"CARD_SPECIFIC_CONDITION",
"VU_SPECIFIC_CONDITION",
"SPEEDING_EVENTS"
);
public RuntimeEventSourceProfile sourceProfile(EventHubEventDto event) {
JsonNode raw = rawPayload(event);
String explicitExtractionCode = normalizeUpper(firstNonBlank(
text(raw, "extractionCode"),
extractionCodeFromExternalSourceEventId(event)
));
String sourceKindCandidate = normalizeUpper(firstNonBlank(
text(raw, "sourceKind"),
sourceKind(event),
sourceKey(event),
sourcePackageKind(event),
sourceKindFromExtractionCode(explicitExtractionCode)
));
RuntimeTachographEvidenceSourceRole sourceRole = evidenceSourceRole(
sourceKindCandidate,
explicitExtractionCode,
event
);
String sourceKind = canonicalSourceKind(sourceRole, sourceKindCandidate);
String extractionCode = normalizeUpper(firstNonBlank(
explicitExtractionCode,
inferExtractionCode(event, sourceRole)
));
if (sourceRole == RuntimeTachographEvidenceSourceRole.UNKNOWN) {
sourceRole = evidenceSourceRole(sourceKind, extractionCode, event);
sourceKind = canonicalSourceKind(sourceRole, sourceKind);
}
String sourceSystemCandidate = normalizeUpper(firstNonBlank(
text(raw, "sourceSystem"),
sourceProvider(event),
sourceSystemFromExternalSourceEventId(event),
sourcePackageKind(event)
));
boolean tachograph = isTachographRepresentation(event, sourceSystemCandidate, extractionCode);
String sourceSystem = tachograph ? "TACHOGRAPH" : sourceSystemCandidate;
RuntimeTachographRepresentation representation = tachograph
? representation(event)
: RuntimeTachographRepresentation.UNKNOWN;
return new RuntimeEventSourceProfile(
sourceSystem,
sourceKind,
extractionCode,
sourceRole,
representation
);
}
/**
* Returns a semantic lifecycle used only for equivalence matching.
* DB place events use START while file-session place events use BEGIN for the same fact.
*/
public String semanticLifecycle(EventHubEventDto event) {
if (event == null || event.lifecycle() == null) {
return null;
}
if (event.eventDomain() == EventDomain.PLACE
&& (event.lifecycle() == EventLifecycle.START || event.lifecycle() == EventLifecycle.BEGIN)) {
return EventLifecycle.BEGIN.name();
}
return event.lifecycle().name();
}
public String inferExtractionCode(EventHubEventDto event, String sourceKind) {
return inferExtractionCode(event, evidenceSourceRole(sourceKind, null, event));
}
public String inferExtractionCode(
EventHubEventDto event,
RuntimeTachographEvidenceSourceRole sourceRole
) {
if (event == null || event.eventDomain() == null || sourceRole == null) {
return null;
}
if (event.eventDomain() == EventDomain.DRIVER_ACTIVITY) {
return switch (sourceRole) {
case DRIVER_CARD -> "CARD_ACTIVITY";
case VEHICLE_UNIT -> "VU_ACTIVITY";
default -> null;
};
}
if (event.eventDomain() == EventDomain.DRIVER_CARD) {
return switch (sourceRole) {
case DRIVER_CARD -> "CARD_VEHICLES_USED";
case VEHICLE_UNIT -> "IW_CYCLE";
default -> null;
};
}
String prefix = switch (sourceRole) {
case DRIVER_CARD -> "CARD";
case VEHICLE_UNIT -> "VU";
default -> null;
};
if (prefix == null) {
return null;
}
return switch (event.eventDomain()) {
case POSITION -> prefix + "_POSITION";
case PLACE -> prefix + "_PLACE";
case BORDER_CROSSING -> prefix + "_BORDER_CROSSING";
case LOAD_UNLOAD -> prefix + "_LOAD_UNLOAD";
case SPECIFIC_CONDITION -> prefix + "_SPECIFIC_CONDITION";
case SPEEDING -> Objects.equals("VU", prefix) ? "SPEEDING_EVENTS" : null;
default -> null;
};
}
public RuntimeTachographEvidenceSourceRole evidenceSourceRole(EventHubEventDto event) {
return sourceProfile(event).evidenceSourceRole();
}
public RuntimeTachographRepresentation representation(EventHubEventDto event) {
String externalId = normalizeUpper(event == null ? null : event.externalSourceEventId());
String packageKind = normalizeUpper(sourcePackageKind(event));
String externalPackageId = normalizeUpper(externalPackageId(event));
String rawSourceSystem = normalizeUpper(text(rawPayload(event), "sourceSystem"));
String provider = normalizeUpper(sourceProvider(event));
String sourceKey = normalizeUpper(sourceKey(event));
// The runtime DB loader intentionally keeps the original source-package metadata.
// Therefore RUNTIME:TACHOGRAPH must take precedence over packageKind markers that may
// still say TACHOGRAPH_FILE_SESSION after the package has been serialized in the DB.
if (startsWithAny(externalPackageId, "RUNTIME:TACHOGRAPH:")) {
return RuntimeTachographRepresentation.DATABASE;
}
if (startsWithAny(externalId,
"TACHOGRAPH_FILE_SESSION:",
"COMPOSITE_TACHOGRAPH_FILE_SESSION:")
|| containsFileSessionMarker(externalPackageId)
|| containsFileSessionMarker(rawSourceSystem)
|| containsFileSessionMarker(provider)
|| containsFileSessionMarker(sourceKey)
|| containsFileSessionMarker(packageKind)) {
return RuntimeTachographRepresentation.FILE_SESSION;
}
if (startsWithAny(externalId, "TACHOGRAPH:")
|| TACHOGRAPH_SOURCE_SYSTEMS.contains(nullToEmpty(normalizeUpper(sourceProvider(event))))
|| KNOWN_EXTRACTION_CODES.contains(nullToEmpty(normalizeUpper(
firstNonBlank(
text(rawPayload(event), "extractionCode"),
extractionCodeFromExternalSourceEventId(event)
)
)))) {
return RuntimeTachographRepresentation.DATABASE;
}
return RuntimeTachographRepresentation.UNKNOWN;
}
public boolean isTachographRepresentation(EventHubEventDto event) {
return sourceProfile(event).isTachographRuntimeSource();
}
private RuntimeTachographEvidenceSourceRole evidenceSourceRole(
String sourceKind,
String extractionCode,
EventHubEventDto event
) {
String normalizedExtraction = normalizeUpper(extractionCode);
if (normalizedExtraction != null) {
if (normalizedExtraction.startsWith("CARD_")) {
return RuntimeTachographEvidenceSourceRole.DRIVER_CARD;
}
if (normalizedExtraction.startsWith("VU_")
|| Objects.equals("IW_CYCLE", normalizedExtraction)
|| Objects.equals("SPEEDING_EVENTS", normalizedExtraction)) {
return RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT;
}
}
String[] candidates = {
sourceKind,
text(rawPayload(event), "sourceKind"),
sourceKind(event),
sourceKey(event),
sourcePackageKind(event)
};
for (String value : candidates) {
RuntimeTachographEvidenceSourceRole resolved = roleFromToken(value);
if (resolved != RuntimeTachographEvidenceSourceRole.UNKNOWN) {
return resolved;
}
}
String externalId = normalizeUpper(event == null ? null : event.externalSourceEventId());
if (externalId != null) {
if (externalId.contains(":CARD_") || externalId.contains(":CARDPLACE-")
|| externalId.contains(":CARDGNSS-")) {
return RuntimeTachographEvidenceSourceRole.DRIVER_CARD;
}
if (externalId.contains(":VU_") || externalId.contains(":VUPLACE-")
|| externalId.contains(":VUGNSS-")) {
return RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT;
}
}
return RuntimeTachographEvidenceSourceRole.UNKNOWN;
}
private RuntimeTachographEvidenceSourceRole roleFromToken(String value) {
String candidate = normalizeUpper(value);
if (candidate == null) {
return RuntimeTachographEvidenceSourceRole.UNKNOWN;
}
if (candidate.equals("DRIVER_CARD")
|| candidate.equals("CARD")
|| candidate.contains("DRIVER_CARD")
|| candidate.startsWith("CARD_")) {
return RuntimeTachographEvidenceSourceRole.DRIVER_CARD;
}
if (candidate.equals("VEHICLE_UNIT")
|| candidate.equals("VU")
|| candidate.contains("VEHICLE_UNIT")
|| candidate.startsWith("VU_")) {
return RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT;
}
return RuntimeTachographEvidenceSourceRole.UNKNOWN;
}
private String canonicalSourceKind(
RuntimeTachographEvidenceSourceRole role,
String fallback
) {
return switch (role == null ? RuntimeTachographEvidenceSourceRole.UNKNOWN : role) {
case DRIVER_CARD -> "DRIVER_CARD";
case VEHICLE_UNIT -> "VEHICLE_UNIT";
case UNKNOWN -> normalizeUpper(fallback);
};
}
private boolean isTachographRepresentation(
EventHubEventDto event,
String sourceSystemCandidate,
String extractionCode
) {
if (TACHOGRAPH_SOURCE_SYSTEMS.contains(nullToEmpty(sourceSystemCandidate))) {
return true;
}
if (KNOWN_EXTRACTION_CODES.contains(nullToEmpty(extractionCode))) {
return true;
}
String packageKind = normalizeUpper(sourcePackageKind(event));
if (TACHOGRAPH_SOURCE_SYSTEMS.contains(nullToEmpty(packageKind))
|| containsFileSessionMarker(packageKind)) {
return true;
}
String sourceKey = normalizeUpper(sourceKey(event));
if (sourceKey != null && sourceKey.startsWith("TACHOGRAPH")) {
return true;
}
String externalId = event == null ? null : event.externalSourceEventId();
return externalId != null
&& (externalId.startsWith("TACHOGRAPH:")
|| externalId.startsWith("TACHOGRAPH_FILE_SESSION:")
|| externalId.startsWith("COMPOSITE_TACHOGRAPH_FILE_SESSION:"));
}
private JsonNode rawPayload(EventHubEventDto event) {
return RuntimeEntityReferenceResolver.rawPayload(event);
}
private String sourceKind(EventHubEventDto event) {
return event == null || event.packageInfo() == null || event.packageInfo().eventSource() == null
? null
: event.packageInfo().eventSource().sourceKind();
}
private String sourceKey(EventHubEventDto event) {
return event == null || event.packageInfo() == null || event.packageInfo().eventSource() == null
? null
: event.packageInfo().eventSource().sourceKey();
}
private String sourceProvider(EventHubEventDto event) {
return event == null || event.packageInfo() == null || event.packageInfo().eventSource() == null
? null
: event.packageInfo().eventSource().providerKey();
}
private String sourcePackageKind(EventHubEventDto event) {
String fromRef = event == null || event.sourcePackageRef() == null
? null
: event.sourcePackageRef().packageKind();
return firstNonBlank(fromRef, text(rawPayload(event), "sourcePackageKind"));
}
private String externalPackageId(EventHubEventDto event) {
return event == null || event.packageInfo() == null ? null : event.packageInfo().externalPackageId();
}
private String sourceSystemFromExternalSourceEventId(EventHubEventDto event) {
String externalId = event == null ? null : event.externalSourceEventId();
if (externalId == null || externalId.isBlank()) {
return null;
}
String[] parts = externalId.split(":");
return parts.length >= 1 ? parts[0] : null;
}
private String extractionCodeFromExternalSourceEventId(EventHubEventDto event) {
String externalId = event == null ? null : event.externalSourceEventId();
if (externalId == null || externalId.isBlank()) {
return null;
}
String[] parts = externalId.split(":");
if (parts.length < 2 || !"TACHOGRAPH".equals(normalizeUpper(parts[0]))) {
return null;
}
String candidate = normalizeUpper(parts[1]);
return KNOWN_EXTRACTION_CODES.contains(nullToEmpty(candidate)) ? candidate : null;
}
private String sourceKindFromExtractionCode(String extractionCode) {
RuntimeTachographEvidenceSourceRole role = evidenceSourceRole(null, extractionCode, null);
return canonicalSourceKind(role, null);
}
private boolean containsFileSessionMarker(String value) {
return value != null && value.contains("FILE_SESSION");
}
private boolean startsWithAny(String value, String... prefixes) {
if (value == null || prefixes == null) {
return false;
}
for (String prefix : prefixes) {
if (prefix != null && value.startsWith(prefix)) {
return true;
}
}
return false;
}
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();
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value.trim();
}
}
return null;
}
private String normalizeUpper(String value) {
return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT);
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
}

View File

@ -0,0 +1,14 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
/**
* Semantic tachograph evidence side used by runtime mixing rules.
*
* <p>This is intentionally independent from the physical representation. The same
* driver-card or vehicle-unit fact can be loaded from a file session or from the
* tachograph database.</p>
*/
public enum RuntimeTachographEvidenceSourceRole {
DRIVER_CARD,
VEHICLE_UNIT,
UNKNOWN
}

View File

@ -0,0 +1,8 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
/** Physical representation from which a tachograph runtime event was loaded. */
public enum RuntimeTachographRepresentation {
FILE_SESSION,
DATABASE,
UNKNOWN
}

View File

@ -129,7 +129,7 @@ public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcess
UnifiedRuntimeDriverWorkingTimeScopeResultDto result = new UnifiedRuntimeDriverWorkingTimeScopeResultDto(
broadBundle.request(),
broadBundle.mergedEvents().size(),
broadBundle.aggregatedEventCount(),
driverResults.size(),
broadBundle.discoveredVehicles().size(),
broadBundle.discoveredVehicles(),

View File

@ -39,7 +39,7 @@ public class EventEvidenceMixingModule implements RuntimeProcessingModule {
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Event evidence mixing",
"Applies source-aware runtime evidence rules before intervalization. The rule registry currently collapses duplicate tachograph card/VU activity, position, place, and border evidence while keeping CARD_VEHICLES_USED/IW_CYCLE unchanged for vehicle-usage processing.",
"Applies source-aware runtime evidence rules before intervalization. The rule registry collapses duplicate tachograph card/VU evidence and duplicate file-session/database representations while keeping CARD_VEHICLES_USED/IW_CYCLE unchanged for vehicle-usage processing.",
"JAVA",
Set.of(DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY),
Set.of("UnifiedRuntimeEventBundle"),
@ -64,6 +64,15 @@ public class EventEvidenceMixingModule implements RuntimeProcessingModule {
metadata.put("resolvedEventCount", mixed.resolvedEvents().size());
metadata.put("eventMixingDecisionCount", mixed.eventMixingDecisions().size());
metadata.put("eventMixingMode", eventMixingMode(context));
metadata.put("tachographEventCount", mixed.diagnostics().tachographEventCount());
metadata.put("driverCardSourceRoleCount", mixed.diagnostics().driverCardSourceRoleCount());
metadata.put("vehicleUnitSourceRoleCount", mixed.diagnostics().vehicleUnitSourceRoleCount());
metadata.put("unknownSourceRoleCount", mixed.diagnostics().unknownSourceRoleCount());
metadata.put("databaseRepresentationCount", mixed.diagnostics().databaseRepresentationCount());
metadata.put("fileSessionRepresentationCount", mixed.diagnostics().fileSessionRepresentationCount());
metadata.put("unknownRepresentationCount", mixed.diagnostics().unknownRepresentationCount());
metadata.put("candidateGroupCount", mixed.diagnostics().candidateGroupCount());
metadata.put("compatibilityRejectedCount", mixed.diagnostics().compatibilityRejectedCount());
return new RuntimeProcessingModuleResult(
moduleKey(),
RuntimeProcessingModuleStatus.SUCCESS,
@ -89,7 +98,7 @@ public class EventEvidenceMixingModule implements RuntimeProcessingModule {
private List<EventHubEventDto> sourceEvents(RuntimeProcessingModuleContext context) {
RuntimeProcessingModuleResult assemblyResult = context.previousResults().get(DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY);
if (assemblyResult != null && assemblyResult.output() instanceof UnifiedRuntimeEventBundle bundle) {
return bundle.mergedEvents();
return bundle.aggregatedEvents();
}
return context.events();
}

View File

@ -28,7 +28,7 @@ public class RuntimeEventAssemblyModule implements RuntimeProcessingModule {
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Runtime event assembly",
"Loads and merges canonical runtime events from the selected source scope before plan-specific processing.",
"Loads driver seed events and optionally aggregates additional events for discovered vehicles into a broad runtime scope before plan-specific mixing and reconciliation.",
"JAVA",
Set.of(),
Set.of("runtime source selection"),
@ -45,7 +45,9 @@ public class RuntimeEventAssemblyModule implements RuntimeProcessingModule {
metadata.put("vehicleExpansionPaddingMinutes", scopeRequest.vehicleExpansionPaddingMinutes() == null ? 0 : scopeRequest.vehicleExpansionPaddingMinutes());
metadata.put("driverSeedEventCount", bundle.driverSeedEvents().size());
metadata.put("expandedVehicleEventCount", bundle.expandedVehicleEvents().size());
metadata.put("mergedEventCount", bundle.mergedEvents().size());
metadata.put("aggregatedEventCount", bundle.aggregatedEventCount());
// Compatibility alias retained for existing API clients and stored execution metadata.
metadata.put("mergedEventCount", bundle.aggregatedEventCount());
metadata.put("discoveredVehicleCount", bundle.discoveredVehicles().size());
return new RuntimeProcessingModuleResult(
moduleKey(),

View File

@ -141,7 +141,9 @@ public class VehicleEvidenceAttachmentModule implements RuntimeProcessingModule
.mapToInt(partition -> partition.attachedVehicleEvidenceEvents().size())
.sum());
metadata.put("partitionSourceEventCount", partitionSourceEvents.size());
metadata.put("rawMergedEventCount", broadBundle.mergedEvents().size());
metadata.put("rawAggregatedEventCount", broadBundle.aggregatedEventCount());
// Compatibility alias retained for existing execution metadata consumers.
metadata.put("rawMergedEventCount", broadBundle.aggregatedEventCount());
return new RuntimeProcessingModuleResult(
moduleKey(),
RuntimeProcessingModuleStatus.SUCCESS,

View File

@ -34,7 +34,7 @@ public final class DriverWorkingTimeEplEventMapper {
public static List<EventHubEventDto> sourceEvents(RuntimeProcessingModuleContext context) {
RuntimeProcessingModuleResult assemblyResult = context.previousResults().get(DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY);
if (assemblyResult != null && assemblyResult.output() instanceof UnifiedRuntimeEventBundle bundle) {
return safeList(bundle.mergedEvents());
return safeList(bundle.aggregatedEvents());
}
return safeList(context.events());
}

View File

@ -116,7 +116,7 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
descriptors.add(new RuntimeProcessingModuleDescriptorDto(
DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY,
"Runtime event assembly",
"Loads and merges canonical runtime events from the selected source scope before plan-specific processing.",
"Loads driver seed events and optionally aggregates additional events for discovered vehicles into a broad runtime scope before plan-specific mixing and reconciliation.",
"JAVA",
Set.of(),
Set.of("runtime source selection"),

View File

@ -1,8 +1,18 @@
package at.procon.eventhub.processing.model;
import at.procon.eventhub.dto.EventHubEventDto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.List;
/**
* Runtime event scope assembled for subsequent processing modules.
*
* <p>The legacy {@code mergedEvents} component contains the event aggregation represented by this
* bundle. For the assembly-stage bundle, this is the broad scope of driver seed events and optionally
* expanded vehicle events after canonical de-duplication and ordering; it is not the later semantic
* card/VU mixing or interval-reconciliation result. New assembly-stage code should prefer
* {@link #aggregatedEvents()}.
*/
public record UnifiedRuntimeEventBundle(
UnifiedRuntimeProcessingRequest request,
List<EventHubEventDto> driverSeedEvents,
@ -18,4 +28,19 @@ public record UnifiedRuntimeEventBundle(
mergedEvents = mergedEvents == null ? List.of() : List.copyOf(mergedEvents);
notes = notes == null ? List.of() : List.copyOf(notes);
}
/**
* Preferred terminology for the broad event scope assembled before plan-specific mixing.
*
* @return the same immutable list exposed by the legacy {@link #mergedEvents()} accessor
*/
@JsonIgnore
public List<EventHubEventDto> aggregatedEvents() {
return mergedEvents;
}
@JsonIgnore
public int aggregatedEventCount() {
return mergedEvents.size();
}
}

View File

@ -50,7 +50,7 @@ public class RuntimeDriverWorkingTimeScopeProcessingService {
) {
UnifiedRuntimeProcessingRequest request = apiRequest.toRuntimeRequest();
UnifiedRuntimeEventBundle broadBundle = eventAssemblyService.assembleDriverScopedEvents(request);
LinkedHashSet<String> selectedDriverKeys = selectedDriverKeys(request, broadBundle.mergedEvents());
LinkedHashSet<String> selectedDriverKeys = selectedDriverKeys(request, broadBundle.aggregatedEvents());
if (selectedDriverKeys.isEmpty()) {
throw new IllegalArgumentException("No driver partitions could be resolved from the runtime event scope.");
}
@ -107,7 +107,7 @@ public class RuntimeDriverWorkingTimeScopeProcessingService {
return new UnifiedRuntimeDriverWorkingTimeScopeResultDto(
request,
broadBundle.mergedEvents().size(),
broadBundle.aggregatedEventCount(),
driverResults.size(),
broadBundle.discoveredVehicles().size(),
broadBundle.discoveredVehicles(),
@ -151,13 +151,13 @@ public class RuntimeDriverWorkingTimeScopeProcessingService {
String driverKey,
boolean includePartitionDebug
) {
List<EventHubEventDto> directDriverEvents = broadBundle.mergedEvents().stream()
List<EventHubEventDto> directDriverEvents = broadBundle.aggregatedEvents().stream()
.filter(event -> Objects.equals(driverKey(event), driverKey))
.toList();
RuntimeDriverVehicleEvidenceAttachmentResult attachmentResult = vehicleEvidenceAttachmentService.attachVehicleEvidence(
driverKey,
directDriverEvents,
broadBundle.mergedEvents(),
broadBundle.aggregatedEvents(),
request.expandVehicleEvents(),
request.vehicleExpansionPaddingMinutes(),
includePartitionDebug

View File

@ -0,0 +1,151 @@
package at.procon.eventhub.processing.service;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.SourcePackageRefDto;
import at.procon.eventhub.processing.eventprocessing.mixing.RuntimeEventSourceProfile;
import at.procon.eventhub.processing.eventprocessing.mixing.RuntimeTachographEventSemantics;
import at.procon.eventhub.processing.support.RuntimeEntityReferenceResolver;
import at.procon.eventhub.processing.support.RuntimeEventIdentityResolver;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* Aggregates runtime events before plan-specific semantic processing.
*
* <p>This service removes only repeated reads of the same physical source record. It deliberately
* does not collapse merely semantically equal events. Card/VU observations must remain available
* for {@code RuntimeEventMixingService}, while CARD_VEHICLES_USED/IW_CYCLE observations must remain
* available for interval-level reconciliation.</p>
*
* <p>A source record is identified from the strongest available provenance value. File-session
* support events use {@code rawRecordPath}; database events normally use {@code sourceRowId}.
* Generated external event IDs are only a fallback because they are presentation identifiers and
* are not guaranteed to be unique for every extractor section.</p>
*/
@Service
public class RuntimeEventAggregationService {
private final RuntimeTachographEventSemantics tachographSemantics;
@Autowired
public RuntimeEventAggregationService(RuntimeTachographEventSemantics tachographSemantics) {
this.tachographSemantics = tachographSemantics;
}
/** Compatibility constructor used by unit tests and direct loader construction. */
public RuntimeEventAggregationService() {
this(new RuntimeTachographEventSemantics());
}
@SafeVarargs
public final List<EventHubEventDto> aggregateRuntimeEvents(List<EventHubEventDto>... eventGroups) {
LinkedHashMap<String, EventHubEventDto> exactSourceRecords = new LinkedHashMap<>();
if (eventGroups != null) {
for (List<EventHubEventDto> eventGroup : eventGroups) {
appendExactSourceRecords(exactSourceRecords, eventGroup);
}
}
return exactSourceRecords.values().stream()
.sorted(eventComparator())
.toList();
}
/** Compatibility alias for the first implementation name. */
@SafeVarargs
public final List<EventHubEventDto> aggregateExactSourceRecords(List<EventHubEventDto>... eventGroups) {
return aggregateRuntimeEvents(eventGroups);
}
public String exactSourceRecordKey(EventHubEventDto event) {
if (event == null) {
return "NULL_EVENT";
}
RuntimeEventSourceProfile profile = tachographSemantics.sourceProfile(event);
SourcePackageRefDto sourcePackage = event.sourcePackageRef();
JsonNode raw = RuntimeEntityReferenceResolver.rawPayload(event);
return String.join("|",
"SOURCE_RECORD",
nullToEmpty(event.packageInfo() == null ? null : event.packageInfo().tenantKey()),
nullToEmpty(profile.sourceSystem()),
nullToEmpty(profile.sourceKind()),
nullToEmpty(profile.extractionCode()),
nullToEmpty(sourcePackage == null ? null : sourcePackage.packageKind()),
nullToEmpty(sourcePackage == null ? null : sourcePackage.sourcePackageId()),
nullToEmpty(sourcePackage == null ? null : sourcePackage.sourceEntityId()),
sourceRecordIdentity(event, raw),
nullToEmpty(event.eventDomain() == null ? null : event.eventDomain().name()),
nullToEmpty(event.eventType() == null ? null : event.eventType().name()),
nullToEmpty(tachographSemantics.semanticLifecycle(event)),
event.occurredAt() == null ? "" : event.occurredAt().toInstant().toString()
);
}
private String sourceRecordIdentity(EventHubEventDto event, JsonNode raw) {
String rawRecordPath = text(raw, "rawRecordPath");
if (rawRecordPath != null) {
return "RAW_PATH:" + rawRecordPath;
}
String sourceRowId = text(raw, "sourceRowId");
if (sourceRowId != null) {
return "SOURCE_ROW:" + sourceRowId;
}
String supportEventId = text(raw, "supportEventId");
if (supportEventId != null) {
return "SUPPORT_EVENT:" + supportEventId;
}
if (event.externalSourceEventId() != null && !event.externalSourceEventId().isBlank()) {
return "EXTERNAL:" + event.externalSourceEventId().trim();
}
if (event.eventId() != null) {
return "EVENT_ID:" + event.eventId();
}
return "CANONICAL:" + RuntimeEventIdentityResolver.canonicalEventKey(event);
}
private void appendExactSourceRecords(
LinkedHashMap<String, EventHubEventDto> target,
List<EventHubEventDto> events
) {
if (events == null) {
return;
}
for (EventHubEventDto event : events) {
if (event != null) {
target.putIfAbsent(exactSourceRecordKey(event), event);
}
}
}
private Comparator<EventHubEventDto> eventComparator() {
return 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 -> tachographSemantics.semanticLifecycle(event), Comparator.nullsLast(String::compareTo))
.thenComparing(event -> tachographSemantics.sourceProfile(event).extractionCode(), Comparator.nullsLast(String::compareTo))
.thenComparing(this::stableSourceIdentityForSort)
.thenComparing(EventHubEventDto::externalSourceEventId, Comparator.nullsLast(String::compareTo));
}
private String stableSourceIdentityForSort(EventHubEventDto event) {
return sourceRecordIdentity(event, RuntimeEntityReferenceResolver.rawPayload(event));
}
private String text(JsonNode node, String field) {
return RuntimeEntityReferenceResolver.text(node, field);
}
private String nullToEmpty(Object value) {
return value == null ? "" : String.valueOf(value);
}
}

View File

@ -83,11 +83,11 @@ public class UnifiedRuntimeDerivedProjectionService {
String explicitDriverKey
) {
String driverKey = explicitDriverKey == null
? resolveDriverKey(request, eventBundle.mergedEvents())
? resolveDriverKey(request, eventBundle.aggregatedEvents())
: explicitDriverKey;
RuntimeSupportEvidenceNormalizationResult normalizationResult = supportEvidenceNormalizer.normalizeForDriverWorkingTime(
driverKey,
eventBundle.mergedEvents()
eventBundle.aggregatedEvents()
);
List<EventHubEventDto> normalizedEvents = normalizationResult.normalizedEvents();
RuntimeDriverTimeline timeline = timelineReconstructor.reconstruct(
@ -169,7 +169,7 @@ public class UnifiedRuntimeDerivedProjectionService {
eventBundle.driverSeedEvents().size(),
eventBundle.discoveredVehicles().size(),
eventBundle.expandedVehicleEvents().size(),
eventBundle.mergedEvents().size(),
eventBundle.aggregatedEventCount(),
eventBundle.discoveredVehicles(),
projection,
notes,

View File

@ -26,8 +26,8 @@ public class UnifiedRuntimeDriverTimelineService {
UnifiedRuntimeEventBundle bundle = runtimeEventAssemblyService.assembleDriverScopedEvents(request);
return timelineReconstructor.reconstruct(
null,
resolveDriverKey(request, bundle.mergedEvents()),
bundle.mergedEvents()
resolveDriverKey(request, bundle.aggregatedEvents()),
bundle.aggregatedEvents()
);
}

View File

@ -8,14 +8,13 @@ import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.processing.model.UnifiedRuntimeSourceInput;
import at.procon.eventhub.processing.support.RuntimeEventIdentityResolver;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
@ -25,13 +24,25 @@ public class UnifiedRuntimeEventAssemblyService {
private final List<RuntimeDriverEventLoader> driverEventLoaders;
private final List<RuntimeVehicleEventLoader> vehicleEventLoaders;
private final RuntimeEventAggregationService eventAggregationService;
@Autowired
public UnifiedRuntimeEventAssemblyService(
List<RuntimeDriverEventLoader> driverEventLoaders,
List<RuntimeVehicleEventLoader> vehicleEventLoaders,
RuntimeEventAggregationService eventAggregationService
) {
this.driverEventLoaders = List.copyOf(driverEventLoaders);
this.vehicleEventLoaders = List.copyOf(vehicleEventLoaders);
this.eventAggregationService = eventAggregationService;
}
/** Compatibility constructor retained for existing tests and direct construction. */
public UnifiedRuntimeEventAssemblyService(
List<RuntimeDriverEventLoader> driverEventLoaders,
List<RuntimeVehicleEventLoader> vehicleEventLoaders
) {
this.driverEventLoaders = List.copyOf(driverEventLoaders);
this.vehicleEventLoaders = List.copyOf(vehicleEventLoaders);
this(driverEventLoaders, vehicleEventLoaders, new RuntimeEventAggregationService());
}
public UnifiedRuntimeEventBundle assembleDriverScopedEvents(UnifiedRuntimeProcessingRequest request) {
@ -42,8 +53,8 @@ public class UnifiedRuntimeEventAssemblyService {
List<EventHubEventDto> expandedVehicleEvents = expandVehicleEvents
? loadExpandedVehicleEvents(request, discoveredVehicles)
: List.of();
List<EventHubEventDto> mergedEvents = expandVehicleEvents
? deduplicateAndSort(driverSeedEvents, expandedVehicleEvents)
List<EventHubEventDto> aggregatedEvents = expandVehicleEvents
? eventAggregationService.aggregateRuntimeEvents(driverSeedEvents, expandedVehicleEvents)
: driverSeedEvents;
List<String> notes = new ArrayList<>();
@ -71,26 +82,28 @@ public class UnifiedRuntimeEventAssemblyService {
}
}
if (expandVehicleEvents) {
notes.add("Vehicle expansion loaded additional events for vehicles discovered in the driver seed set.");
notes.add("Vehicle expansion aggregated additional events for vehicles discovered in the driver seed set.");
notes.add("Vehicle expansion padding minutes: " + request.vehicleExpansionPaddingMinutes() + ".");
} else {
notes.add("Vehicle expansion was disabled for this runtime request.");
}
notes.add("Runtime aggregation removes repeated reads and duplicate serialized representations of the same extraction observation while preserving card/VU equivalents and CARD_VEHICLES_USED/IW_CYCLE evidence for later processing.");
notes.add("The assembled event set is a broad aggregated runtime scope; semantic card/VU mixing and interval reconciliation are performed by later modules.");
LOG.info(
"Runtime event assembly completed (expandVehicleEvents: {}, sourceInputs: {}, driverSeedEvents: {}, discoveredVehicles: {}, expandedVehicleEvents: {}, mergedEvents: {})",
"Runtime event assembly completed (expandVehicleEvents: {}, sourceInputs: {}, driverSeedEvents: {}, discoveredVehicles: {}, expandedVehicleEvents: {}, aggregatedEvents: {})",
expandVehicleEvents,
sourceInputs.size(),
driverSeedEvents.size(),
discoveredVehicles.size(),
expandedVehicleEvents.size(),
mergedEvents.size()
aggregatedEvents.size()
);
return new UnifiedRuntimeEventBundle(
request,
driverSeedEvents,
discoveredVehicles,
expandedVehicleEvents,
mergedEvents,
aggregatedEvents,
notes
);
}
@ -106,7 +119,7 @@ public class UnifiedRuntimeEventAssemblyService {
result.addAll(loader.loadDriverEvents(sourceRequest));
}
}
return deduplicateAndSort(result, List.of());
return eventAggregationService.aggregateRuntimeEvents(result);
}
private List<EventHubEventDto> loadExpandedVehicleEvents(
@ -125,7 +138,7 @@ public class UnifiedRuntimeEventAssemblyService {
}
}
}
return deduplicateAndSort(result, List.of());
return eventAggregationService.aggregateRuntimeEvents(result);
}
private List<UnifiedDiscoveredVehicleRef> discoverVehicles(List<EventHubEventDto> events) {
@ -165,28 +178,6 @@ public class UnifiedRuntimeEventAssemblyService {
return List.copyOf(result);
}
private List<EventHubEventDto> deduplicateAndSort(
List<EventHubEventDto> left,
List<EventHubEventDto> right
) {
LinkedHashMap<String, EventHubEventDto> byKey = new LinkedHashMap<>();
appendDeduplicated(byKey, left);
appendDeduplicated(byKey, right);
return byKey.values().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 void appendDeduplicated(LinkedHashMap<String, EventHubEventDto> byKey, List<EventHubEventDto> events) {
for (EventHubEventDto event : events) {
byKey.putIfAbsent(RuntimeEventIdentityResolver.canonicalEventKey(event), event);
}
}
private RuntimeDriverEventLoader driverLoader(UnifiedRuntimeProcessingRequest request, UnifiedEventSourceFamily sourceFamily) {
return driverEventLoaders.stream()
.filter(loader -> loader.supports(request, sourceFamily))

View File

@ -16,6 +16,7 @@ import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.processing.service.RuntimeDriverEventLoader;
import at.procon.eventhub.processing.service.RuntimeEventAggregationService;
import at.procon.eventhub.processing.service.RuntimeVehicleEventLoader;
import at.procon.eventhub.reference.TachographNationRegistry;
import at.procon.eventhub.tachograph.dto.TachographImportRequest;
@ -30,6 +31,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.core.io.Resource;
@ -45,15 +47,28 @@ public class TachographDbRuntimeEventLoader implements RuntimeDriverEventLoader,
private final NamedParameterJdbcTemplate jdbcTemplate;
private final TachographExtractionDefinitionRegistry definitionRegistry;
private final ResourceLoader resourceLoader;
private final RuntimeEventAggregationService eventAggregationService;
@Autowired
public TachographDbRuntimeEventLoader(
@Qualifier("tachographNamedParameterJdbcTemplate") NamedParameterJdbcTemplate jdbcTemplate,
TachographExtractionDefinitionRegistry definitionRegistry,
ResourceLoader resourceLoader
ResourceLoader resourceLoader,
RuntimeEventAggregationService eventAggregationService
) {
this.jdbcTemplate = jdbcTemplate;
this.definitionRegistry = definitionRegistry;
this.resourceLoader = resourceLoader;
this.eventAggregationService = eventAggregationService;
}
/** Compatibility constructor retained for direct construction. */
public TachographDbRuntimeEventLoader(
NamedParameterJdbcTemplate jdbcTemplate,
TachographExtractionDefinitionRegistry definitionRegistry,
ResourceLoader resourceLoader
) {
this(jdbcTemplate, definitionRegistry, resourceLoader, new RuntimeEventAggregationService());
}
@Override
@ -74,7 +89,7 @@ public class TachographDbRuntimeEventLoader implements RuntimeDriverEventLoader,
request.occurredTo()
));
}
return List.copyOf(result);
return eventAggregationService.aggregateRuntimeEvents(result);
}
@ -93,7 +108,7 @@ public class TachographDbRuntimeEventLoader implements RuntimeDriverEventLoader,
request.vehicleOccurredTo()
));
}
return List.copyOf(result);
return eventAggregationService.aggregateRuntimeEvents(result);
}
private List<EventHubEventDto> queryDefinition(

View File

@ -11,14 +11,15 @@ import at.procon.eventhub.processing.service.RuntimeDriverEventLoader;
import at.procon.eventhub.processing.service.RuntimeVehicleEventLoader;
import at.procon.eventhub.processing.service.UnifiedDriverEventSourceService;
import at.procon.eventhub.processing.service.UnifiedVehicleEventSourceService;
import at.procon.eventhub.processing.service.RuntimeEventAggregationService;
import at.procon.eventhub.service.EventAcquisitionRecordKeyService;
import at.procon.eventhub.service.EventHubEventSorter;
import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionNotFoundException;
import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionRepository;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
@ -27,21 +28,35 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
private final UnifiedDriverEventSourceService driverEventSourceService;
private final UnifiedVehicleEventSourceService vehicleEventSourceService;
private final TachographCompositeSessionRepository compositeSessionRepository;
private final EventAcquisitionRecordKeyService eventKeyService;
private final EventHubEventSorter eventSorter;
private final RuntimeEventAggregationService eventAggregationService;
@Autowired
public TachographFileSessionRuntimeEventLoader(
UnifiedDriverEventSourceService driverEventSourceService,
UnifiedVehicleEventSourceService vehicleEventSourceService,
TachographCompositeSessionRepository compositeSessionRepository,
EventAcquisitionRecordKeyService eventKeyService,
EventHubEventSorter eventSorter
RuntimeEventAggregationService eventAggregationService
) {
this.driverEventSourceService = driverEventSourceService;
this.vehicleEventSourceService = vehicleEventSourceService;
this.compositeSessionRepository = compositeSessionRepository;
this.eventKeyService = eventKeyService;
this.eventSorter = eventSorter;
this.eventAggregationService = eventAggregationService;
}
/** Compatibility constructor retained for existing tests and direct construction. */
public TachographFileSessionRuntimeEventLoader(
UnifiedDriverEventSourceService driverEventSourceService,
UnifiedVehicleEventSourceService vehicleEventSourceService,
TachographCompositeSessionRepository compositeSessionRepository,
EventAcquisitionRecordKeyService ignoredEventKeyService,
EventHubEventSorter ignoredEventSorter
) {
this(
driverEventSourceService,
vehicleEventSourceService,
compositeSessionRepository,
new RuntimeEventAggregationService()
);
}
@Override
@ -64,7 +79,7 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
)
));
}
return deduplicateBySignatureAndSort(result);
return eventAggregationService.aggregateRuntimeEvents(result);
}
@Override
@ -87,7 +102,7 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
)
));
}
return deduplicateBySignatureAndSort(result);
return eventAggregationService.aggregateRuntimeEvents(result);
}
private List<UUID> resolveSessionIds(UnifiedRuntimeProcessingRequest request) {
@ -99,11 +114,5 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
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

@ -288,7 +288,13 @@ create schema DailyWeeklyRestCandidateCoverageEmittedKey(
endedAtEpochSecond long
);
@public create context PerDriver partition by driverKey from TachographVehicleUsageIntervalInputEvent;
@public create context PerDriver partition by driverKey from DriverWorkingTimeVehicleUsageIntervalInputEvent;
@public create window DriverWorkingTimeVehicleUsageIntervalInputWindow#keepall as DriverWorkingTimeVehicleUsageIntervalInputEvent;
insert into DriverWorkingTimeVehicleUsageIntervalInputWindow
select *
from DriverWorkingTimeVehicleUsageIntervalInputEvent;
create schema VuCardAbsentInterval(
sessionId java.util.UUID,
@ -304,6 +310,8 @@ create schema VuCardAbsentInterval(
nextVehicleKey string
);
@public create window VuCardAbsentIntervalWindow#keepall as VuCardAbsentInterval;
create schema PotentialHomeOvernightStayInterval(
sessionId java.util.UUID,
driverKey string,
@ -473,7 +481,7 @@ select
longitude,
odometerKm,
priority
from TachographSupportGeoEvidenceInputEvent;
from DriverWorkingTimeSupportEvidenceInputEvent;
insert into SignificantDrivingInterval
select
@ -486,7 +494,7 @@ select
durationSeconds,
registrationKey,
vehicleKey
from TachographActivityIntervalInputEvent(activityType = 'DRIVE', durationSeconds > ${SIGNIFICANT_DRIVING_THRESHOLD_SECONDS});
from DriverWorkingTimeActivityIntervalInputEvent(activityType = 'DRIVE', durationSeconds > ${SIGNIFICANT_DRIVING_THRESHOLD_SECONDS});
@public create window PreviousSignificantDrivingInterval#unique(driverKey) as SignificantDrivingInterval;
@ -562,7 +570,7 @@ select
c.previousVehicleKey as previousVehicleKey,
c.nextVehicleKey as nextVehicleKey
from DailyWeeklyRestCandidateInterval as c unidirectional,
VuCardAbsentInterval#keepall as u
VuCardAbsentIntervalWindow as u
where u.driverKey = c.driverKey
and u.startedAtEpochSecond < c.endedAtEpochSecond
and u.endedAtEpochSecond > c.startedAtEpochSecond
@ -595,7 +603,7 @@ select
c.nextVehicleKey as nextVehicleKey
from DailyWeeklyRestCandidateInterval as c
where not exists (
select * from VuCardAbsentInterval#keepall as u
select * from VuCardAbsentIntervalWindow as u
where u.driverKey = c.driverKey
and u.startedAtEpochSecond < c.endedAtEpochSecond
and u.endedAtEpochSecond > c.startedAtEpochSecond
@ -649,7 +657,7 @@ select
end
) as rankScore
from DailyWeeklyRestCandidateCoverageCardResolvedInterval as c unidirectional,
TachographVehicleUsageIntervalInputEvent#keepall as v
DriverWorkingTimeVehicleUsageIntervalInputWindow as v
where v.driverKey = c.driverKey
and v.odometerBeginKm is not null
and v.startedAtEpochSecond >= c.startedAtEpochSecond - ${REST_GEO_LOOKBACK_SECONDS}
@ -696,7 +704,7 @@ select
end
) as rankScore
from DailyWeeklyRestCandidateCoverageCardResolvedInterval as c unidirectional,
TachographVehicleUsageIntervalInputEvent#keepall as v
DriverWorkingTimeVehicleUsageIntervalInputWindow as v
where v.driverKey = c.driverKey
and v.endedAtEpochSecond is not null
and v.odometerEndKm is not null
@ -772,7 +780,7 @@ select
end
) as rankScore
from DailyWeeklyRestCandidateCoverageCardResolvedInterval as c unidirectional,
TachographVehicleUsageIntervalInputEvent#keepall as v
DriverWorkingTimeVehicleUsageIntervalInputWindow as v
where v.driverKey = c.driverKey
and v.odometerBeginKm is not null
and v.startedAtEpochSecond >= c.endedAtEpochSecond - ${REST_GEO_LOOKBACK_SECONDS}
@ -819,7 +827,7 @@ select
end
) as rankScore
from DailyWeeklyRestCandidateCoverageCardResolvedInterval as c unidirectional,
TachographVehicleUsageIntervalInputEvent#keepall as v
DriverWorkingTimeVehicleUsageIntervalInputWindow as v
where v.driverKey = c.driverKey
and v.endedAtEpochSecond is not null
and v.odometerEndKm is not null
@ -1484,11 +1492,11 @@ select
from DailyWeeklyRestCandidateCoverageInterval;
@public context PerDriver
create window PreviousVehicleUsageInterval#lastevent as TachographVehicleUsageIntervalInputEvent;
create window PreviousVehicleUsageInterval#lastevent as DriverWorkingTimeVehicleUsageIntervalInputEvent;
@Priority(30)
context PerDriver
on TachographVehicleUsageIntervalInputEvent as next
on DriverWorkingTimeVehicleUsageIntervalInputEvent as next
insert into VuCardAbsentInterval
select
priorInterval.sessionId as sessionId,
@ -1509,12 +1517,12 @@ where priorInterval.endedAt is not null
@Priority(20)
context PerDriver
on TachographVehicleUsageIntervalInputEvent
on DriverWorkingTimeVehicleUsageIntervalInputEvent
delete from PreviousVehicleUsageInterval;
@Priority(10)
context PerDriver
on TachographVehicleUsageIntervalInputEvent as current
on DriverWorkingTimeVehicleUsageIntervalInputEvent as current
insert into PreviousVehicleUsageInterval
select *;
@ -1776,6 +1784,9 @@ select * from DailyWeeklyRestCandidateCoverageInterval;
@name('drivingInterruptionVehicleChangeIntervals')
select * from DrivingInterruptionVehicleChangeInterval;
insert into VuCardAbsentIntervalWindow
select * from VuCardAbsentInterval;
@name('vuCardAbsentIntervals')
select * from VuCardAbsentInterval;

View File

@ -1,8 +1,8 @@
/*
* Source-neutral event-input adapter for driver-working-time-derived-projections.epl.
*
* The old bundle consumes resolved interval input streams. This preprocessor lets the same
* derived rules consume EventHub point events by pairing START/END activity events and
* The projection bundle consumes resolved interval input streams. This preprocessor lets the
* same derived rules consume EventHub point events by pairing START/END activity events and
* INSERT/WITHDRAW card-vehicle usage events inside Esper.
*
* Vehicle-usage intervals are additionally coalesced in EPL before they are forwarded to
@ -11,15 +11,15 @@
* common midnight continuation 23:59:59 -> 00:00:00 on the next day.
*/
create window OpenActivityPoint#unique(driverKey, intervalId) as TachographActivityPointInputEvent;
create window OpenActivityPoint#unique(driverKey, intervalId) as DriverWorkingTimeActivityPointInputEvent;
insert into OpenActivityPoint
select *
from TachographActivityPointInputEvent(lifecycle = 'START');
from DriverWorkingTimeActivityPointInputEvent(lifecycle = 'START');
@Priority(20)
on TachographActivityPointInputEvent(lifecycle = 'END') as endEvent
insert into TachographActivityIntervalInputEvent
on DriverWorkingTimeActivityPointInputEvent(lifecycle = 'END') as endEvent
insert into DriverWorkingTimeActivityIntervalInputEvent
select
startEvent.sessionId as sessionId,
startEvent.driverKey as driverKey,
@ -48,7 +48,7 @@ where startEvent.driverKey = endEvent.driverKey
and endEvent.occurredAtEpochSecond > startEvent.occurredAtEpochSecond;
@Priority(10)
on TachographActivityPointInputEvent(lifecycle = 'END') as endEvent
on DriverWorkingTimeActivityPointInputEvent(lifecycle = 'END') as endEvent
delete from OpenActivityPoint as openEvent
where openEvent.driverKey = endEvent.driverKey
and openEvent.intervalId = endEvent.intervalId;
@ -72,14 +72,14 @@ create schema RawVehicleUsageInterval(
sourceIntervalIds java.util.List
);
create window OpenVehicleUsagePoint#unique(driverKey, intervalId) as TachographVehicleUsagePointInputEvent;
create window OpenVehicleUsagePoint#unique(driverKey, intervalId) as DriverWorkingTimeVehicleUsagePointInputEvent;
insert into OpenVehicleUsagePoint
select *
from TachographVehicleUsagePointInputEvent(lifecycle = 'INSERT');
from DriverWorkingTimeVehicleUsagePointInputEvent(lifecycle = 'INSERT');
@Priority(20)
on TachographVehicleUsagePointInputEvent(lifecycle = 'WITHDRAW') as withdrawEvent
on DriverWorkingTimeVehicleUsagePointInputEvent(lifecycle = 'WITHDRAW') as withdrawEvent
insert into RawVehicleUsageInterval
select
insertEvent.sessionId as sessionId,
@ -104,7 +104,7 @@ where insertEvent.driverKey = withdrawEvent.driverKey
and withdrawEvent.occurredAtEpochSecond > insertEvent.occurredAtEpochSecond;
@Priority(10)
on TachographVehicleUsagePointInputEvent(lifecycle = 'WITHDRAW') as withdrawEvent
on DriverWorkingTimeVehicleUsagePointInputEvent(lifecycle = 'WITHDRAW') as withdrawEvent
delete from OpenVehicleUsagePoint as openEvent
where openEvent.driverKey = withdrawEvent.driverKey
and openEvent.intervalId = withdrawEvent.intervalId;
@ -192,7 +192,7 @@ where current.driverKey = next.driverKey
*/
@Priority(70)
on RawVehicleUsageInterval as next
insert into TachographVehicleUsageIntervalInputEvent
insert into DriverWorkingTimeVehicleUsageIntervalInputEvent
select
current.sessionId as sessionId,
current.driverKey as driverKey,
@ -316,12 +316,12 @@ where candidate.driverKey = next.driverKey;
/*
* The last accumulated interval cannot be emitted by looking at the next interval, so Java
* sends one TachographProjectionFinalizeEvent per driver after all vehicle-usage point
* sends one DriverWorkingTimeProjectionFinalizeEvent per driver after all vehicle-usage point
* events and before activity point events.
*/
@Priority(20)
on TachographProjectionFinalizeEvent as finalizeEvent
insert into TachographVehicleUsageIntervalInputEvent
on DriverWorkingTimeProjectionFinalizeEvent as finalizeEvent
insert into DriverWorkingTimeVehicleUsageIntervalInputEvent
select
current.sessionId as sessionId,
current.driverKey as driverKey,
@ -343,6 +343,6 @@ from MergedVehicleUsageAccumulator as current
where current.driverKey = finalizeEvent.driverKey;
@Priority(10)
on TachographProjectionFinalizeEvent as finalizeEvent
on DriverWorkingTimeProjectionFinalizeEvent as finalizeEvent
delete from MergedVehicleUsageAccumulator as current
where current.driverKey = finalizeEvent.driverKey;

View File

@ -282,6 +282,12 @@ create schema DailyWeeklyRestCandidateCoverageEmittedKey(
create context PerDriver partition by driverKey from TachographVehicleUsageIntervalInputEvent;
@public create window TachographVehicleUsageIntervalInputWindow#keepall as TachographVehicleUsageIntervalInputEvent;
insert into TachographVehicleUsageIntervalInputWindow
select *
from TachographVehicleUsageIntervalInputEvent;
create schema VuCardAbsentInterval(
sessionId java.util.UUID,
driverKey string,
@ -296,6 +302,8 @@ create schema VuCardAbsentInterval(
nextVehicleKey string
);
@public create window VuCardAbsentIntervalWindow#keepall as VuCardAbsentInterval;
create schema PotentialHomeOvernightStayInterval(
sessionId java.util.UUID,
driverKey string,
@ -554,7 +562,7 @@ select
c.previousVehicleKey as previousVehicleKey,
c.nextVehicleKey as nextVehicleKey
from DailyWeeklyRestCandidateInterval as c unidirectional,
VuCardAbsentInterval#keepall as u
VuCardAbsentIntervalWindow as u
where u.driverKey = c.driverKey
and u.startedAtEpochSecond < c.endedAtEpochSecond
and u.endedAtEpochSecond > c.startedAtEpochSecond
@ -587,7 +595,7 @@ select
c.nextVehicleKey as nextVehicleKey
from DailyWeeklyRestCandidateInterval as c
where not exists (
select * from VuCardAbsentInterval#keepall as u
select * from VuCardAbsentIntervalWindow as u
where u.driverKey = c.driverKey
and u.startedAtEpochSecond < c.endedAtEpochSecond
and u.endedAtEpochSecond > c.startedAtEpochSecond
@ -641,7 +649,7 @@ select
end
) as rankScore
from DailyWeeklyRestCandidateCoverageCardResolvedInterval as c unidirectional,
TachographVehicleUsageIntervalInputEvent#keepall as v
TachographVehicleUsageIntervalInputWindow as v
where v.driverKey = c.driverKey
and v.odometerBeginKm is not null
and v.startedAtEpochSecond >= c.startedAtEpochSecond - ${REST_GEO_LOOKBACK_SECONDS}
@ -688,7 +696,7 @@ select
end
) as rankScore
from DailyWeeklyRestCandidateCoverageCardResolvedInterval as c unidirectional,
TachographVehicleUsageIntervalInputEvent#keepall as v
TachographVehicleUsageIntervalInputWindow as v
where v.driverKey = c.driverKey
and v.endedAtEpochSecond is not null
and v.odometerEndKm is not null
@ -764,7 +772,7 @@ select
end
) as rankScore
from DailyWeeklyRestCandidateCoverageCardResolvedInterval as c unidirectional,
TachographVehicleUsageIntervalInputEvent#keepall as v
TachographVehicleUsageIntervalInputWindow as v
where v.driverKey = c.driverKey
and v.odometerBeginKm is not null
and v.startedAtEpochSecond >= c.endedAtEpochSecond - ${REST_GEO_LOOKBACK_SECONDS}
@ -811,7 +819,7 @@ select
end
) as rankScore
from DailyWeeklyRestCandidateCoverageCardResolvedInterval as c unidirectional,
TachographVehicleUsageIntervalInputEvent#keepall as v
TachographVehicleUsageIntervalInputWindow as v
where v.driverKey = c.driverKey
and v.endedAtEpochSecond is not null
and v.odometerEndKm is not null
@ -1768,6 +1776,9 @@ select * from DailyWeeklyRestCandidateCoverageInterval;
@name('drivingInterruptionVehicleChangeIntervals')
select * from DrivingInterruptionVehicleChangeInterval;
insert into VuCardAbsentIntervalWindow
select * from VuCardAbsentInterval;
@name('vuCardAbsentIntervals')
select * from VuCardAbsentInterval;

View File

@ -80,7 +80,7 @@ select
base.country,
base.region,
'WORKING_DAY_PLACE_RECORDED' as event_type,
case when base.entry_type in (0, 2, 4) then 'START' else 'END' end as lifecycle,
case when base.entry_type in (0, 2, 4, 6) then 'START' else 'END' end as lifecycle,
cast(case when base.entry_type in (2, 3, 4, 5) then 1 else 0 end as bit) as manual_entry,
cast(base.driver_id as varchar(128)) as driver_source_entity_id,

View File

@ -58,7 +58,7 @@ select
base.country,
base.region,
'WORKING_DAY_PLACE_RECORDED' as event_type,
case when base.entry_type in (0, 2, 4) then 'START' else 'END' end as lifecycle,
case when base.entry_type in (0, 2, 4, 6) then 'START' else 'END' end as lifecycle,
cast(case when base.entry_type in (2, 3, 4, 5) then 1 else 0 end as bit) as manual_entry,
cast(base.driver_id as varchar(128)) as driver_source_entity_id,

View File

@ -3,17 +3,62 @@ package at.procon.eventhub.processing.driverworkingtime.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.processing.driverworkingtime.esper.DriverWorkingTimeEsperContractNames;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeDerivedProjectionBundle;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeProcessingInput;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.StreamUtils;
import org.junit.jupiter.api.Test;
class DriverWorkingTimeReusableProjectionBuilderTest {
@Test
void commonEplUsesSourceNeutralDriverWorkingTimeInputContracts() throws IOException {
String projectionEpl = StreamUtils.copyToString(
new ClassPathResource("esper/driver-working-time-derived-projections.epl").getInputStream(),
StandardCharsets.UTF_8
);
String preprocessorEpl = StreamUtils.copyToString(
new ClassPathResource("esper/runtime-driver-event-interval-preprocessor.epl").getInputStream(),
StandardCharsets.UTF_8
);
assertThat(projectionEpl)
.contains(DriverWorkingTimeEsperContractNames.ACTIVITY_INTERVAL_INPUT_EVENT_TYPE)
.contains(DriverWorkingTimeEsperContractNames.VEHICLE_USAGE_INTERVAL_INPUT_EVENT_TYPE)
.contains(DriverWorkingTimeEsperContractNames.SUPPORT_EVIDENCE_INPUT_EVENT_TYPE)
.contains(DriverWorkingTimeEsperContractNames.VEHICLE_USAGE_INTERVAL_INPUT_WINDOW)
.doesNotContain("TachographActivityIntervalInputEvent")
.doesNotContain("TachographVehicleUsageIntervalInputEvent")
.doesNotContain("TachographSupportGeoEvidenceInputEvent")
.doesNotContain("TachographVehicleUsageIntervalInputWindow");
assertThat(preprocessorEpl)
.contains(DriverWorkingTimeEsperContractNames.ACTIVITY_POINT_INPUT_EVENT_TYPE)
.contains(DriverWorkingTimeEsperContractNames.VEHICLE_USAGE_POINT_INPUT_EVENT_TYPE)
.contains(DriverWorkingTimeEsperContractNames.ACTIVITY_INTERVAL_INPUT_EVENT_TYPE)
.contains(DriverWorkingTimeEsperContractNames.VEHICLE_USAGE_INTERVAL_INPUT_EVENT_TYPE)
.contains(DriverWorkingTimeEsperContractNames.PROJECTION_FINALIZE_EVENT_TYPE)
.doesNotContain("TachographActivityPointInputEvent")
.doesNotContain("TachographVehicleUsagePointInputEvent")
.doesNotContain("TachographProjectionFinalizeEvent");
assertThat(DriverWorkingTimeReusableProjectionBuilder.REUSABLE_RUNTIME_STATE_CLEANUP_QUERIES)
.contains("delete from "
+ DriverWorkingTimeEsperContractNames.VEHICLE_USAGE_INTERVAL_INPUT_WINDOW);
}
@Test
void reusesWarmRuntimeWithoutLeakingPreviousState() {
DriverWorkingTimeReusableProjectionBuilder builder =
@ -112,4 +157,270 @@ class DriverWorkingTimeReusableProjectionBuilderTest {
assertThat(second).isEqualTo(first);
assertThat(second.drivingInterruptionIntervals()).hasSize(1);
}
@Test
void clearsVuCardAbsentWindowWhenRuntimeIsReused() {
DriverWorkingTimeReusableProjectionBuilder builder =
new DriverWorkingTimeReusableProjectionBuilder(new EventHubProperties());
UUID sessionId = UUID.randomUUID();
OffsetDateTime from = OffsetDateTime.parse("2026-05-01T08:00:00Z");
OffsetDateTime firstDriveEnd = OffsetDateTime.parse("2026-05-01T09:00:00Z");
OffsetDateTime secondDriveStart = OffsetDateTime.parse("2026-05-01T12:00:00Z");
OffsetDateTime to = OffsetDateTime.parse("2026-05-01T13:00:00Z");
DriverWorkingTimeProcessingInput input = new DriverWorkingTimeProcessingInput(
sessionId,
"12:123",
"DRIVER_CARD",
from,
to,
from,
to,
15,
30,
List.of(
new DriverWorkingTimeActivityInterval(
sessionId,
"12:123",
"ACT-1",
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
"ACT-1",
"ACT-1",
from,
firstDriveEnd,
from.toEpochSecond(),
firstDriveEnd.toEpochSecond(),
firstDriveEnd.toEpochSecond() - from.toEpochSecond(),
List.of("ACT-1"),
false,
false,
"RAW_INTERVAL"
),
new DriverWorkingTimeActivityInterval(
sessionId,
"12:123",
"ACT-2",
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
"ACT-2",
"ACT-2",
secondDriveStart,
to,
secondDriveStart.toEpochSecond(),
to.toEpochSecond(),
to.toEpochSecond() - secondDriveStart.toEpochSecond(),
List.of("ACT-2"),
false,
false,
"RAW_INTERVAL"
)
),
List.of(
new DriverWorkingTimeVehicleUsageInterval(
sessionId,
"12:123",
"VU-1",
"VU-1",
"VU-1",
from,
firstDriveEnd,
from.toEpochSecond(),
firstDriveEnd.toEpochSecond(),
firstDriveEnd.toEpochSecond() - from.toEpochSecond(),
100L,
150L,
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
List.of("VU-1")
),
new DriverWorkingTimeVehicleUsageInterval(
sessionId,
"12:123",
"VU-2",
"VU-2",
"VU-2",
secondDriveStart,
to,
secondDriveStart.toEpochSecond(),
to.toEpochSecond(),
to.toEpochSecond() - secondDriveStart.toEpochSecond(),
150L,
200L,
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
List.of("VU-2")
)
),
List.of(),
List.of()
);
DriverWorkingTimeDerivedProjectionBundle first = builder.buildDerivedProjectionBundle(input);
DriverWorkingTimeDerivedProjectionBundle second = builder.buildDerivedProjectionBundle(input);
assertThat(first.vuCardAbsentIntervals()).hasSize(1);
assertThat(second.vuCardAbsentIntervals()).hasSize(1);
assertThat(first.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1);
assertThat(second.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1);
assertThat(second.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentDurationSeconds())
.isEqualTo(first.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentDurationSeconds());
assertThat(second.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentCoveragePercent())
.isEqualTo(first.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentCoveragePercent())
.isLessThanOrEqualTo(100.0d);
}
@Test
void cleanupContractCoversEveryPublicNamedWindow() throws IOException {
String epl = StreamUtils.copyToString(
new ClassPathResource("esper/driver-working-time-derived-projections.epl").getInputStream(),
StandardCharsets.UTF_8
);
Matcher matcher = Pattern.compile(
"(?m)@public(?:\\s+context\\s+[A-Za-z0-9_]+)?\\s*create\\s+window\\s+([A-Za-z0-9_]+)"
).matcher(epl);
Set<String> publicNamedWindows = new LinkedHashSet<>();
while (matcher.find()) {
publicNamedWindows.add(matcher.group(1));
}
assertThat(publicNamedWindows).isNotEmpty();
for (String windowName : publicNamedWindows) {
assertThat(DriverWorkingTimeReusableProjectionBuilder.REUSABLE_RUNTIME_STATE_CLEANUP_QUERIES)
.anyMatch(query -> query.contains("delete from " + windowName));
}
}
@Test
void clearsRetainedVehicleUsageInputBetweenExecutions() {
DriverWorkingTimeReusableProjectionBuilder builder =
new DriverWorkingTimeReusableProjectionBuilder(new EventHubProperties());
UUID sessionId = UUID.randomUUID();
OffsetDateTime from = OffsetDateTime.parse("2026-05-01T08:00:00Z");
OffsetDateTime firstDriveEnd = OffsetDateTime.parse("2026-05-01T09:00:00Z");
OffsetDateTime secondDriveStart = OffsetDateTime.parse("2026-05-01T12:00:00Z");
OffsetDateTime to = OffsetDateTime.parse("2026-05-01T13:00:00Z");
List<DriverWorkingTimeActivityInterval> activities = List.of(
new DriverWorkingTimeActivityInterval(
sessionId,
"12:123",
"ACT-1",
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
"ACT-1",
"ACT-1",
from,
firstDriveEnd,
from.toEpochSecond(),
firstDriveEnd.toEpochSecond(),
firstDriveEnd.toEpochSecond() - from.toEpochSecond(),
List.of("ACT-1"),
false,
false,
"RAW_INTERVAL"
),
new DriverWorkingTimeActivityInterval(
sessionId,
"12:123",
"ACT-2",
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
"ACT-2",
"ACT-2",
secondDriveStart,
to,
secondDriveStart.toEpochSecond(),
to.toEpochSecond(),
to.toEpochSecond() - secondDriveStart.toEpochSecond(),
List.of("ACT-2"),
false,
false,
"RAW_INTERVAL"
)
);
DriverWorkingTimeProcessingInput withVehicleUsage = new DriverWorkingTimeProcessingInput(
sessionId,
"12:123",
"DRIVER_CARD",
from,
to,
from,
to,
15,
30,
activities,
List.of(new DriverWorkingTimeVehicleUsageInterval(
sessionId,
"12:123",
"VU-1",
"VU-1",
"VU-1",
from,
to,
from.toEpochSecond(),
to.toEpochSecond(),
to.toEpochSecond() - from.toEpochSecond(),
100L,
200L,
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
List.of("VU-1")
)),
List.of(),
List.of()
);
DriverWorkingTimeProcessingInput withoutVehicleUsage = new DriverWorkingTimeProcessingInput(
sessionId,
"12:123",
"DRIVER_CARD",
from,
to,
from,
to,
15,
30,
activities,
List.of(),
List.of(),
List.of()
);
DriverWorkingTimeDerivedProjectionBundle first = builder.buildDerivedProjectionBundle(withVehicleUsage);
DriverWorkingTimeDerivedProjectionBundle second = builder.buildDerivedProjectionBundle(withoutVehicleUsage);
assertThat(first.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1);
assertThat(first.dailyWeeklyRestCandidateCoverageIntervals().get(0).beginBoundaryOdometerKm())
.isNotNull();
assertThat(second.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1);
assertThat(second.dailyWeeklyRestCandidateCoverageIntervals().get(0).beginBoundaryOdometerKm())
.isNull();
assertThat(second.dailyWeeklyRestCandidateCoverageIntervals().get(0).endBoundaryOdometerKm())
.isNull();
}
}

View File

@ -16,6 +16,7 @@ import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
@ -423,6 +424,388 @@ class RuntimeEventMixingServiceTest {
.containsExactlyInAnyOrder("VU_LOAD_UNLOAD", "VU_SPECIFIC_CONDITION");
}
@Test
void mixesFileSessionCardPlaceWithDatabaseVuPlaceAcrossRepresentationDifferences() {
OffsetDateTime occurredAt = OffsetDateTime.parse("2026-04-01T04:30:59Z");
EventHubEventDto fileCard = crossRepresentationPlace(
true,
"TACHOGRAPH_FILE_SESSION:card-place-28",
occurredAt,
"13",
"0",
new BigDecimal("50.6400000000000000"),
new BigDecimal("9.3883333333333336")
);
EventHubEventDto databaseVu = crossRepresentationPlace(
false,
"4693459",
occurredAt,
"D",
null,
new BigDecimal("50.64"),
new BigDecimal("9.388333333333334")
);
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileCard, databaseVu),
RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
);
assertThat(mixed.supportEvidenceEvents()).hasSize(1);
assertThat(mixed.supportEvidenceEvents().getFirst().externalSourceEventId())
.isEqualTo(fileCard.externalSourceEventId());
assertThat(mixed.supportEvidenceEvents().getFirst().vehicleRef().vin())
.isEqualTo("XLRTEH4300G376073");
assertThat(mixed.suppressedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly(databaseVu.externalSourceEventId());
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
.isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_SUPPORT_COMPATIBLE_KEY);
}
@Test
void mixesFileSessionCardActivityWithDatabaseVuActivity() {
OffsetDateTime occurredAt = OffsetDateTime.parse("2026-04-01T05:22:00Z");
EventHubEventDto fileCard = crossRepresentationActivity(
true,
"TACHOGRAPH_FILE_SESSION:card-activity-1",
occurredAt
);
EventHubEventDto databaseVu = crossRepresentationActivity(
false,
"116710708",
occurredAt
);
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileCard, databaseVu),
RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
);
assertThat(mixed.activityTimelineEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly(fileCard.externalSourceEventId());
assertThat(mixed.suppressedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly(databaseVu.externalSourceEventId());
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
.isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_ACTIVITY_COMPATIBLE_KEY);
}
@Test
void suppressesFileSessionVuPlaceWhenSameVuFactIsLoadedFromDatabase() {
OffsetDateTime occurredAt = OffsetDateTime.parse("2026-04-01T04:30:59Z");
EventHubEventDto fileVu = sameRoleCrossRepresentationPlace(
true,
"VEHICLE_UNIT",
"TACHOGRAPH_FILE_SESSION:vu-place-28",
occurredAt,
"13",
"0",
new BigDecimal("50.6400000000000000"),
new BigDecimal("9.3883333333333336")
);
EventHubEventDto databaseVu = sameRoleCrossRepresentationPlace(
false,
"VEHICLE_UNIT",
"4693459",
occurredAt,
"D",
null,
new BigDecimal("50.64"),
new BigDecimal("9.388333333333334")
);
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileVu, databaseVu),
RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly(databaseVu.externalSourceEventId());
assertThat(mixed.suppressedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly(fileVu.externalSourceEventId());
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
.isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_DB_FILE_SESSION_SUPPORT_SAME_ROLE);
assertThat(mixed.diagnostics().vehicleUnitSourceRoleCount()).isEqualTo(2);
assertThat(mixed.diagnostics().databaseRepresentationCount()).isEqualTo(1);
assertThat(mixed.diagnostics().fileSessionRepresentationCount()).isEqualTo(1);
}
@Test
void suppressesFileSessionCardActivityWhenSameCardFactIsLoadedFromDatabase() {
OffsetDateTime occurredAt = OffsetDateTime.parse("2026-04-01T05:22:00Z");
EventHubEventDto fileCard = sameRoleCrossRepresentationActivity(
true,
"DRIVER_CARD",
"TACHOGRAPH_FILE_SESSION:card-activity-1",
occurredAt
);
EventHubEventDto databaseCard = sameRoleCrossRepresentationActivity(
false,
"DRIVER_CARD",
"116710708",
occurredAt
);
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileCard, databaseCard),
RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
);
assertThat(mixed.activityTimelineEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly(databaseCard.externalSourceEventId());
assertThat(mixed.suppressedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly(fileCard.externalSourceEventId());
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
.isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_DB_FILE_SESSION_ACTIVITY_SAME_ROLE);
}
@Test
void keepsCrossRepresentationSupportEventsWhenMeaningfulCoordinatesConflict() {
OffsetDateTime occurredAt = OffsetDateTime.parse("2026-04-01T04:30:59Z");
EventHubEventDto fileCard = crossRepresentationPlace(
true,
"TACHOGRAPH_FILE_SESSION:card-place-conflict",
occurredAt,
"13",
"0",
new BigDecimal("50.64"),
new BigDecimal("9.3883333333333336")
);
EventHubEventDto databaseVu = crossRepresentationPlace(
false,
"4693460",
occurredAt,
"D",
null,
new BigDecimal("50.64"),
new BigDecimal("10.0")
);
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileCard, databaseVu),
RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactlyInAnyOrder(fileCard.externalSourceEventId(), databaseVu.externalSourceEventId());
assertThat(mixed.suppressedEvents()).isEmpty();
assertThat(mixed.eventMixingDecisions()).isEmpty();
}
private EventHubEventDto sameRoleCrossRepresentationPlace(
boolean fileSession,
String sourceKind,
String externalId,
OffsetDateTime occurredAt,
String country,
String region,
BigDecimal latitude,
BigDecimal longitude
) {
ObjectNode raw = fileSession
? baseRawWithoutExtraction(sourceKind)
: baseRaw(sourceKind.equals("DRIVER_CARD") ? "CARD_PLACE" : "VU_PLACE", sourceKind);
raw.put("latitude", latitude.toPlainString());
raw.put("longitude", longitude.toPlainString());
raw.put("odometerM", "172945000");
raw.put("country", country);
if (region != null) {
raw.put("region", region);
}
ObjectNode payload = JsonNodeFactory.instance.objectNode();
payload.set("raw", raw);
ObjectNode attributes = JsonNodeFactory.instance.objectNode();
attributes.put("country", country);
if (region != null) {
attributes.put("region", region);
}
VehicleRefDto vehicleRef = fileSession
? new VehicleRefDto(null, null, null, new VehicleRegistrationRefDto("13", 13, "RO BS 2219"))
: new VehicleRefDto("3342", "XLRTEH4300G376073", null, new VehicleRegistrationRefDto("D", 13, "RO BS 2219"));
return new EventHubEventDto(
UUID.randomUUID(),
externalId,
new DriverRefDto("13:DF000358328840", new DriverCardRefDto("13", 13, "DF000358328840")),
vehicleRef,
occurredAt,
null,
occurredAt,
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
fileSession ? EventLifecycle.BEGIN : EventLifecycle.START,
172945000L,
new at.procon.eventhub.dto.GeoPointDto(latitude, longitude),
new EventDetailsDto("PLACE", attributes),
null,
payload,
false,
packageInfo(
fileSession ? "default" : "TENANT_A",
fileSession ? "TACHOGRAPH_FILE_SESSION" : "TACHOGRAPH",
sourceKind,
EventDomain.PLACE,
occurredAt.toLocalDate()
)
);
}
private EventHubEventDto sameRoleCrossRepresentationActivity(
boolean fileSession,
String sourceKind,
String externalId,
OffsetDateTime occurredAt
) {
String extractionCode = sourceKind.equals("DRIVER_CARD") ? "CARD_ACTIVITY" : "VU_ACTIVITY";
ObjectNode raw = fileSession
? baseRawWithoutExtraction(sourceKind)
: baseRaw(extractionCode, sourceKind);
raw.put("activityType", "DRIVE");
raw.put("cardSlot", "DRIVER");
raw.put("startedAt", occurredAt.toString());
raw.put("endedAt", occurredAt.plusMinutes(30).toString());
ObjectNode payload = JsonNodeFactory.instance.objectNode();
payload.set("raw", raw);
ObjectNode attributes = JsonNodeFactory.instance.objectNode();
attributes.put("cardSlot", "DRIVER");
return new EventHubEventDto(
UUID.randomUUID(),
externalId,
new DriverRefDto("13:DF000358328840", new DriverCardRefDto("13", 13, "DF000358328840")),
new VehicleRefDto(null, null, null, new VehicleRegistrationRefDto("13", 13, "RO BS 2219")),
occurredAt,
null,
occurredAt,
EventDomain.DRIVER_ACTIVITY,
EventType.DRIVE,
EventLifecycle.START,
null,
null,
new EventDetailsDto("DRIVER_ACTIVITY", attributes),
null,
payload,
false,
packageInfo(
fileSession ? "default" : "TENANT_A",
fileSession ? "TACHOGRAPH_FILE_SESSION" : "TACHOGRAPH",
sourceKind,
EventDomain.DRIVER_ACTIVITY,
occurredAt.toLocalDate()
)
);
}
private EventHubEventDto crossRepresentationPlace(
boolean fileSessionCard,
String externalId,
OffsetDateTime occurredAt,
String country,
String region,
BigDecimal latitude,
BigDecimal longitude
) {
String sourceKind = fileSessionCard ? "DRIVER_CARD" : "VEHICLE_UNIT";
ObjectNode raw = fileSessionCard
? baseRawWithoutExtraction(sourceKind)
: baseRaw("VU_PLACE", sourceKind);
raw.put("latitude", latitude.toPlainString());
raw.put("longitude", longitude.toPlainString());
raw.put("odometerM", "172945000");
raw.put("country", country);
if (region != null) {
raw.put("region", region);
}
ObjectNode payload = JsonNodeFactory.instance.objectNode();
payload.set("raw", raw);
ObjectNode attributes = JsonNodeFactory.instance.objectNode();
attributes.put("country", country);
if (region != null) {
attributes.put("region", region);
}
VehicleRefDto vehicleRef = fileSessionCard
? new VehicleRefDto(null, null, null, new VehicleRegistrationRefDto("13", 13, "RO BS 2219"))
: new VehicleRefDto("3342", "XLRTEH4300G376073", null, new VehicleRegistrationRefDto("D", 13, "RO BS 2219"));
EventHubEventDto event = new EventHubEventDto(
UUID.randomUUID(),
externalId,
new DriverRefDto("13:DF000358328840", new DriverCardRefDto("13", 13, "DF000358328840")),
vehicleRef,
occurredAt,
null,
occurredAt,
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
fileSessionCard ? EventLifecycle.BEGIN : EventLifecycle.START,
172945000L,
new at.procon.eventhub.dto.GeoPointDto(latitude, longitude),
new EventDetailsDto("PLACE", attributes),
null,
payload,
false,
packageInfo(
fileSessionCard ? "default" : "TENANT_A",
fileSessionCard ? "TACHOGRAPH_FILE_SESSION" : "TACHOGRAPH",
sourceKind,
EventDomain.PLACE,
occurredAt.toLocalDate()
)
);
return event;
}
private EventHubEventDto crossRepresentationActivity(
boolean fileSessionCard,
String externalId,
OffsetDateTime occurredAt
) {
String sourceKind = fileSessionCard ? "DRIVER_CARD" : "VEHICLE_UNIT";
ObjectNode raw = fileSessionCard
? baseRawWithoutExtraction(sourceKind)
: baseRaw("VU_ACTIVITY", sourceKind);
raw.put("activityType", "DRIVE");
raw.put("cardSlot", "DRIVER");
raw.put("startedAt", "2026-04-01T05:22:00Z");
raw.put("endedAt", "2026-04-01T05:52:00Z");
if (fileSessionCard) {
raw.put("cardStatus", "INSERTED");
raw.put("drivingStatus", "SINGLE");
}
ObjectNode payload = JsonNodeFactory.instance.objectNode();
payload.set("raw", raw);
ObjectNode attributes = JsonNodeFactory.instance.objectNode();
attributes.put("cardSlot", "DRIVER");
VehicleRefDto vehicleRef = fileSessionCard
? new VehicleRefDto(null, null, null, new VehicleRegistrationRefDto("13", 13, "RO BS 2219"))
: new VehicleRefDto("3342", "XLRTEH4300G376073", null, new VehicleRegistrationRefDto("D", 13, "RO BS 2219"));
return new EventHubEventDto(
UUID.randomUUID(),
externalId,
new DriverRefDto("13:DF000358328840", new DriverCardRefDto("13", 13, "DF000358328840")),
vehicleRef,
occurredAt,
null,
occurredAt,
EventDomain.DRIVER_ACTIVITY,
EventType.DRIVE,
EventLifecycle.START,
null,
null,
new EventDetailsDto("DRIVER_ACTIVITY", attributes),
null,
payload,
false,
packageInfo(
fileSessionCard ? "default" : "TENANT_A",
fileSessionCard ? "TACHOGRAPH_FILE_SESSION" : "TACHOGRAPH",
sourceKind,
EventDomain.DRIVER_ACTIVITY,
occurredAt.toLocalDate()
)
);
}
private EventHubEventDto activity(String extractionCode, String sourceKind, String externalId) {
ObjectNode raw = baseRaw(extractionCode, sourceKind);
raw.put("activityType", "BREAK_REST");
@ -665,6 +1048,27 @@ class RuntimeEventMixingServiceTest {
);
}
private EventHubPackageRequest packageInfo(
String tenantKey,
String providerKey,
String sourceKind,
EventDomain domain,
LocalDate businessDate
) {
EventSourceDto source = new EventSourceDto(providerKey, sourceKind, providerKey + "_" + sourceKind, null, null, null);
OffsetDateTime from = businessDate.atStartOfDay().atOffset(java.time.ZoneOffset.UTC);
OffsetDateTime to = businessDate.plusDays(1).atStartOfDay().atOffset(java.time.ZoneOffset.UTC);
return new EventHubPackageRequest(
tenantKey,
source,
null,
ImportScopeDto.tenantAll(from, to),
domain.name(),
businessDate,
source.stableKey() + ":" + domain.name() + ":" + businessDate
);
}
private EventHubPackageRequest packageInfo(String sourceKind, EventDomain domain, LocalDate businessDate) {
EventSourceDto source = new EventSourceDto("TACHOGRAPH", sourceKind, "TACHOGRAPH_" + sourceKind, null, null, null);
OffsetDateTime from = businessDate.atStartOfDay().atOffset(java.time.ZoneOffset.UTC);

View File

@ -0,0 +1,343 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.dto.DriverCardRefDto;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventDetailsDto;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventHubPackageRequest;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventSourceDto;
import at.procon.eventhub.dto.EventType;
import at.procon.eventhub.dto.ImportScopeDto;
import at.procon.eventhub.dto.SourcePackageRefDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.OffsetDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
class RuntimeTachographRepresentationParityTest {
private static final OffsetDateTime OCCURRED_AT = OffsetDateTime.parse("2026-04-01T08:00:00Z");
private final RuntimeEventDescriptorFactory descriptorFactory = new RuntimeEventDescriptorFactory();
private final RuntimeEventMixingService mixingService = new RuntimeEventMixingService();
@Test
void producesSameCanonicalSourceProfileForDbAndFileSessionPlaceRepresentations() {
EventHubEventDto dbEvent = event(
Representation.DB,
"DRIVER_CARD",
"CARD_PLACE",
"TACHOGRAPH:CARD_PLACE:10",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.START
);
EventHubEventDto fileSessionEvent = event(
Representation.FILE_SESSION,
"DRIVER_CARD",
null,
"TACHOGRAPH_FILE_SESSION:11111111-1111-1111-1111-111111111111:SUPPORT:place-10:BEGIN:2026-04-01T08:00:00Z",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.BEGIN
);
RuntimeEventDescriptor dbDescriptor = descriptorFactory.describe(dbEvent);
RuntimeEventDescriptor fileDescriptor = descriptorFactory.describe(fileSessionEvent);
assertThat(dbDescriptor.sourceProfile().sourceSystem()).isEqualTo("TACHOGRAPH");
assertThat(fileDescriptor.sourceProfile().sourceSystem()).isEqualTo("TACHOGRAPH");
assertThat(dbDescriptor.sourceProfile().sourceKind()).isEqualTo("DRIVER_CARD");
assertThat(fileDescriptor.sourceProfile().sourceKind()).isEqualTo("DRIVER_CARD");
assertThat(dbDescriptor.sourceProfile().extractionCode()).isEqualTo("CARD_PLACE");
assertThat(fileDescriptor.sourceProfile().extractionCode()).isEqualTo("CARD_PLACE");
assertThat(dbDescriptor.evidenceSourceRole())
.isEqualTo(RuntimeTachographEvidenceSourceRole.DRIVER_CARD);
assertThat(fileDescriptor.evidenceSourceRole())
.isEqualTo(RuntimeTachographEvidenceSourceRole.DRIVER_CARD);
assertThat(dbDescriptor.representation())
.isEqualTo(RuntimeTachographRepresentation.DATABASE);
assertThat(fileDescriptor.representation())
.isEqualTo(RuntimeTachographRepresentation.FILE_SESSION);
assertThat(fileDescriptor.compatibleSupportEvidenceKey())
.isEqualTo(dbDescriptor.compatibleSupportEvidenceKey());
}
@Test
void infersSameVehicleUsageExtractionProfilesForDbAndFileSessionRepresentations() {
EventHubEventDto dbCvu = event(
Representation.DB,
"DRIVER_CARD",
"CARD_VEHICLES_USED",
"TACHOGRAPH:CARD_VEHICLES_USED:10:INSERT",
EventDomain.DRIVER_CARD,
EventType.CARD_INSERTED,
EventLifecycle.INSERT
);
EventHubEventDto fileCvu = event(
Representation.FILE_SESSION,
"DRIVER_CARD",
null,
"TACHOGRAPH_FILE_SESSION:11111111-1111-1111-1111-111111111111:CARD_USAGE:cvu-10:INSERT:2026-04-01T08:00:00Z",
EventDomain.DRIVER_CARD,
EventType.CARD_INSERTED,
EventLifecycle.INSERT
);
EventHubEventDto dbIw = event(
Representation.DB,
"VEHICLE_UNIT",
"IW_CYCLE",
"TACHOGRAPH:IW_CYCLE:20:INSERT",
EventDomain.DRIVER_CARD,
EventType.CARD_INSERTED,
EventLifecycle.INSERT
);
EventHubEventDto fileIw = event(
Representation.FILE_SESSION,
"VEHICLE_UNIT",
null,
"TACHOGRAPH_FILE_SESSION:22222222-2222-2222-2222-222222222222:CARD_USAGE:iw-20:INSERT:2026-04-01T08:00:00Z",
EventDomain.DRIVER_CARD,
EventType.CARD_INSERTED,
EventLifecycle.INSERT
);
RuntimeEventSourceProfile dbCvuProfile = descriptorFactory.sourceProfile(dbCvu);
RuntimeEventSourceProfile fileCvuProfile = descriptorFactory.sourceProfile(fileCvu);
RuntimeEventSourceProfile dbIwProfile = descriptorFactory.sourceProfile(dbIw);
RuntimeEventSourceProfile fileIwProfile = descriptorFactory.sourceProfile(fileIw);
assertThat(fileCvuProfile.sourceKind()).isEqualTo(dbCvuProfile.sourceKind());
assertThat(fileCvuProfile.extractionCode()).isEqualTo(dbCvuProfile.extractionCode());
assertThat(fileCvuProfile.evidenceSourceRole()).isEqualTo(dbCvuProfile.evidenceSourceRole());
assertThat(fileIwProfile.sourceKind()).isEqualTo(dbIwProfile.sourceKind());
assertThat(fileIwProfile.extractionCode()).isEqualTo(dbIwProfile.extractionCode());
assertThat(fileIwProfile.evidenceSourceRole()).isEqualTo(dbIwProfile.evidenceSourceRole());
assertThat(dbCvuProfile.extractionCode()).isEqualTo("CARD_VEHICLES_USED");
assertThat(dbIwProfile.extractionCode()).isEqualTo("IW_CYCLE");
assertThat(fileCvuProfile.representation()).isEqualTo(RuntimeTachographRepresentation.FILE_SESSION);
assertThat(dbCvuProfile.representation()).isEqualTo(RuntimeTachographRepresentation.DATABASE);
}
@Test
void producesEquivalentMixingOutcomeForDbAndFileSessionSupportPairs() {
RuntimeMixedEventBundle dbMixed = mixingService.mix(
List.of(
event(
Representation.DB,
"DRIVER_CARD",
"CARD_PLACE",
"TACHOGRAPH:CARD_PLACE:10",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.START
),
event(
Representation.DB,
"VEHICLE_UNIT",
"VU_PLACE",
"TACHOGRAPH:VU_PLACE:20",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.START
)
),
RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
);
RuntimeMixedEventBundle fileMixed = mixingService.mix(
List.of(
event(
Representation.FILE_SESSION,
"DRIVER_CARD",
null,
"TACHOGRAPH_FILE_SESSION:11111111-1111-1111-1111-111111111111:SUPPORT:card-place-10:BEGIN:2026-04-01T08:00:00Z",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.BEGIN
),
event(
Representation.FILE_SESSION,
"VEHICLE_UNIT",
null,
"TACHOGRAPH_FILE_SESSION:22222222-2222-2222-2222-222222222222:SUPPORT:vu-place-20:BEGIN:2026-04-01T08:00:00Z",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.BEGIN
)
),
RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
);
assertThat(fileMixed.supportEvidenceEvents()).hasSameSizeAs(dbMixed.supportEvidenceEvents());
assertThat(fileMixed.suppressedEvents()).hasSameSizeAs(dbMixed.suppressedEvents());
assertThat(fileMixed.eventMixingDecisions()).hasSameSizeAs(dbMixed.eventMixingDecisions());
assertThat(fileMixed.eventMixingDecisions().getFirst().primaryExtractionCode())
.isEqualTo(dbMixed.eventMixingDecisions().getFirst().primaryExtractionCode());
assertThat(fileMixed.eventMixingDecisions().getFirst().secondaryExtractionCodes())
.isEqualTo(dbMixed.eventMixingDecisions().getFirst().secondaryExtractionCodes());
assertThat(fileMixed.eventMixingDecisions().getFirst().ruleId())
.isEqualTo(dbMixed.eventMixingDecisions().getFirst().ruleId());
}
@Test
void producesEquivalentMixingOutcomeForDbAndFileSessionActivityPairs() {
RuntimeMixedEventBundle dbMixed = mixingService.mix(
List.of(
event(Representation.DB, "DRIVER_CARD", "CARD_ACTIVITY",
"TACHOGRAPH:CARD_ACTIVITY:10:START",
EventDomain.DRIVER_ACTIVITY, EventType.DRIVE, EventLifecycle.START),
event(Representation.DB, "VEHICLE_UNIT", "VU_ACTIVITY",
"TACHOGRAPH:VU_ACTIVITY:20:START",
EventDomain.DRIVER_ACTIVITY, EventType.DRIVE, EventLifecycle.START)
),
RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
);
RuntimeMixedEventBundle fileMixed = mixingService.mix(
List.of(
event(Representation.FILE_SESSION, "DRIVER_CARD", null,
"TACHOGRAPH_FILE_SESSION:11111111-1111-1111-1111-111111111111:ACTIVITY:card-10:START:2026-04-01T08:00:00Z",
EventDomain.DRIVER_ACTIVITY, EventType.DRIVE, EventLifecycle.START),
event(Representation.FILE_SESSION, "VEHICLE_UNIT", null,
"TACHOGRAPH_FILE_SESSION:22222222-2222-2222-2222-222222222222:ACTIVITY:vu-20:START:2026-04-01T08:00:00Z",
EventDomain.DRIVER_ACTIVITY, EventType.DRIVE, EventLifecycle.START)
),
RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
);
assertThat(fileMixed.activityTimelineEvents()).hasSameSizeAs(dbMixed.activityTimelineEvents());
assertThat(fileMixed.suppressedEvents()).hasSameSizeAs(dbMixed.suppressedEvents());
assertThat(fileMixed.eventMixingDecisions()).hasSameSizeAs(dbMixed.eventMixingDecisions());
assertThat(fileMixed.eventMixingDecisions().getFirst().primaryExtractionCode())
.isEqualTo(dbMixed.eventMixingDecisions().getFirst().primaryExtractionCode());
assertThat(fileMixed.eventMixingDecisions().getFirst().secondaryExtractionCodes())
.isEqualTo(dbMixed.eventMixingDecisions().getFirst().secondaryExtractionCodes());
}
@Test
void preservesEquivalentCvuAndIwCycleInputsForBothRepresentations() {
List<EventHubEventDto> dbEvents = List.of(
event(Representation.DB, "DRIVER_CARD", "CARD_VEHICLES_USED",
"TACHOGRAPH:CARD_VEHICLES_USED:10:INSERT",
EventDomain.DRIVER_CARD, EventType.CARD_INSERTED, EventLifecycle.INSERT),
event(Representation.DB, "DRIVER_CARD", "CARD_VEHICLES_USED",
"TACHOGRAPH:CARD_VEHICLES_USED:10:WITHDRAW",
EventDomain.DRIVER_CARD, EventType.CARD_WITHDRAWN, EventLifecycle.WITHDRAW),
event(Representation.DB, "VEHICLE_UNIT", "IW_CYCLE",
"TACHOGRAPH:IW_CYCLE:20:INSERT",
EventDomain.DRIVER_CARD, EventType.CARD_INSERTED, EventLifecycle.INSERT),
event(Representation.DB, "VEHICLE_UNIT", "IW_CYCLE",
"TACHOGRAPH:IW_CYCLE:20:WITHDRAW",
EventDomain.DRIVER_CARD, EventType.CARD_WITHDRAWN, EventLifecycle.WITHDRAW)
);
List<EventHubEventDto> fileEvents = List.of(
event(Representation.FILE_SESSION, "DRIVER_CARD", null,
"TACHOGRAPH_FILE_SESSION:11111111-1111-1111-1111-111111111111:CARD_USAGE:cvu-10:INSERT:2026-04-01T08:00:00Z",
EventDomain.DRIVER_CARD, EventType.CARD_INSERTED, EventLifecycle.INSERT),
event(Representation.FILE_SESSION, "DRIVER_CARD", null,
"TACHOGRAPH_FILE_SESSION:11111111-1111-1111-1111-111111111111:CARD_USAGE:cvu-10:WITHDRAW:2026-04-01T08:00:00Z",
EventDomain.DRIVER_CARD, EventType.CARD_WITHDRAWN, EventLifecycle.WITHDRAW),
event(Representation.FILE_SESSION, "VEHICLE_UNIT", null,
"TACHOGRAPH_FILE_SESSION:22222222-2222-2222-2222-222222222222:CARD_USAGE:iw-20:INSERT:2026-04-01T08:00:00Z",
EventDomain.DRIVER_CARD, EventType.CARD_INSERTED, EventLifecycle.INSERT),
event(Representation.FILE_SESSION, "VEHICLE_UNIT", null,
"TACHOGRAPH_FILE_SESSION:22222222-2222-2222-2222-222222222222:CARD_USAGE:iw-20:WITHDRAW:2026-04-01T08:00:00Z",
EventDomain.DRIVER_CARD, EventType.CARD_WITHDRAWN, EventLifecycle.WITHDRAW)
);
RuntimeMixedEventBundle dbMixed = mixingService.mix(
dbEvents, RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
RuntimeMixedEventBundle fileMixed = mixingService.mix(
fileEvents, RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
assertThat(dbMixed.vehicleUsageEvents()).hasSize(4);
assertThat(fileMixed.vehicleUsageEvents()).hasSize(4);
assertThat(dbMixed.suppressedEvents()).isEmpty();
assertThat(fileMixed.suppressedEvents()).isEmpty();
assertThat(fileEvents).extracting(event -> descriptorFactory.sourceProfile(event).extractionCode())
.containsExactly("CARD_VEHICLES_USED", "CARD_VEHICLES_USED", "IW_CYCLE", "IW_CYCLE");
}
private EventHubEventDto event(
Representation representation,
String sourceKind,
String extractionCode,
String externalSourceEventId,
EventDomain domain,
EventType type,
EventLifecycle lifecycle
) {
String provider = representation == Representation.DB ? "TACHOGRAPH" : "TACHOGRAPH_FILE_SESSION";
EventSourceDto source = new EventSourceDto(
provider,
sourceKind,
provider + "_" + sourceKind,
null,
null,
null
);
EventHubPackageRequest packageInfo = new EventHubPackageRequest(
"default",
source,
null,
ImportScopeDto.tenantAll(null, null),
domain.name(),
null,
representation == Representation.DB
? "RUNTIME:TACHOGRAPH:" + (extractionCode == null ? "UNKNOWN" : extractionCode) + ":test"
: provider + ":package"
);
ObjectNode raw = JsonNodeFactory.instance.objectNode();
raw.put("sourceKind", sourceKind);
raw.put("driverKey", "12:123");
raw.put("registrationKey", "12:REG-1");
if (extractionCode != null) {
raw.put("extractionCode", extractionCode);
}
ObjectNode payload = JsonNodeFactory.instance.objectNode();
payload.set("raw", raw);
return new EventHubEventDto(
null,
externalSourceEventId,
new DriverRefDto("driver-1", new DriverCardRefDto("12", "123")),
new VehicleRefDto(
sourceKind.equals("VEHICLE_UNIT") ? "vehicle-1" : null,
sourceKind.equals("VEHICLE_UNIT") ? "VIN-1" : null,
new VehicleRegistrationRefDto("12", "REG-1")
),
OCCURRED_AT,
null,
null,
domain,
type,
lifecycle,
null,
null,
new EventDetailsDto(domain.name(), JsonNodeFactory.instance.objectNode()),
new SourcePackageRefDto(
"TACHOGRAPH_FILE_SESSION",
representation == Representation.DB ? "package-1" : "session-1",
sourceKind.equals("VEHICLE_UNIT") ? "vehicle-1" : "card-1",
null,
null,
null
),
payload,
false,
packageInfo
);
}
private enum Representation {
DB,
FILE_SESSION
}
}

View File

@ -0,0 +1,118 @@
package at.procon.eventhub.processing.eventprocessing.module;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
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.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingExecutionApiRequest;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle;
import at.procon.eventhub.processing.service.UnifiedRuntimeEventAssemblyService;
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 RuntimeEventAssemblyModuleTest {
@Test
void exposesAggregatedTerminologyAndKeepsMergedCountCompatibilityAlias() {
UnifiedRuntimeEventAssemblyService service =
org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
RuntimeEventAssemblyModule module = new RuntimeEventAssemblyModule(service);
UnifiedRuntimeProcessingApiRequest scope = scopeRequest();
EventHubEventDto first = event("EVENT-1", "2026-05-01T08:00:00Z");
EventHubEventDto second = event("EVENT-2", "2026-05-01T09:00:00Z");
UnifiedRuntimeEventBundle bundle = new UnifiedRuntimeEventBundle(
scope.toRuntimeRequest(),
List.of(first),
List.of(),
List.of(second),
List.of(first, second),
List.of("assembled")
);
when(service.assembleDriverScopedEvents(any())).thenReturn(bundle);
RuntimeProcessingModuleResult result = module.execute(new RuntimeProcessingModuleContext(
new RuntimeProcessingExecutionApiRequest(
"driver-working-time-v1",
scope,
null,
List.of(),
Map.of()
),
List.of(),
Map.of("runtimeScopeApiRequest", scope),
Map.of()
));
assertThat(module.descriptor().description())
.contains("aggregates additional events")
.contains("mixing and reconciliation");
assertThat(result.output()).isSameAs(bundle);
assertThat(result.metadata())
.containsEntry("aggregatedEventCount", 2)
.containsEntry("mergedEventCount", 2);
}
private UnifiedRuntimeProcessingApiRequest scopeRequest() {
return new UnifiedRuntimeProcessingApiRequest(
UUID.randomUUID(),
List.of(),
null,
"default",
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
null,
null,
"12:123",
Set.of(),
false,
Set.of(),
false,
null,
null,
null,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
true,
15,
true,
null,
null,
null,
null,
List.of()
);
}
private EventHubEventDto event(String externalId, String occurredAt) {
OffsetDateTime timestamp = OffsetDateTime.parse(occurredAt);
return new EventHubEventDto(
UUID.randomUUID(),
externalId,
new DriverRefDto("12:123", null),
null,
timestamp,
null,
timestamp,
EventDomain.DRIVER_ACTIVITY,
EventType.DRIVE,
EventLifecycle.START,
null,
null,
null,
null,
null,
false,
null
);
}
}

View File

@ -0,0 +1,301 @@
package at.procon.eventhub.processing.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.dto.DriverCardRefDto;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventDetailsDto;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventHubPackageRequest;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventSourceDto;
import at.procon.eventhub.dto.EventType;
import at.procon.eventhub.dto.ImportScopeDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.OffsetDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
class RuntimeEventAggregationServiceTest {
private final RuntimeEventAggregationService service = new RuntimeEventAggregationService();
@Test
void removesOnlyRepeatedReadsOfSameSourceRecord() {
EventHubEventDto cardPosition = event(
"DRIVER_CARD",
"CARD_POSITION",
"TACHOGRAPH:CARD_POSITION:10",
EventDomain.POSITION,
EventType.POSITION_RECORDED,
EventLifecycle.SNAPSHOT,
"10",
null,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
EventHubEventDto vuPosition = event(
"VEHICLE_UNIT",
"VU_POSITION",
"TACHOGRAPH:VU_POSITION:20",
EventDomain.POSITION,
EventType.POSITION_RECORDED,
EventLifecycle.SNAPSHOT,
"20",
null,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
EventHubEventDto cvu = event(
"DRIVER_CARD",
"CARD_VEHICLES_USED",
"TACHOGRAPH:CARD_VEHICLES_USED:30:INSERT",
EventDomain.DRIVER_CARD,
EventType.CARD_INSERTED,
EventLifecycle.INSERT,
"30",
null,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
EventHubEventDto iwCycle = event(
"VEHICLE_UNIT",
"IW_CYCLE",
"TACHOGRAPH:IW_CYCLE:40:INSERT",
EventDomain.DRIVER_CARD,
EventType.CARD_INSERTED,
EventLifecycle.INSERT,
"40",
null,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
List<EventHubEventDto> aggregated = service.aggregateRuntimeEvents(
List.of(cardPosition, vuPosition, cvu, iwCycle),
List.of(cardPosition)
);
assertThat(aggregated).hasSize(4);
assertThat(aggregated).extracting(EventHubEventDto::externalSourceEventId)
.containsExactlyInAnyOrder(
cardPosition.externalSourceEventId(),
vuPosition.externalSourceEventId(),
cvu.externalSourceEventId(),
iwCycle.externalSourceEventId()
);
}
@Test
void deduplicatesRepresentationsSharingTheSameSourceRowIdentity() {
EventHubEventDto first = event(
"DRIVER_CARD",
"CARD_POSITION",
"TACHOGRAPH:CARD_POSITION:10",
EventDomain.POSITION,
EventType.POSITION_RECORDED,
EventLifecycle.SNAPSHOT,
"10",
null,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
EventHubEventDto serializedCopy = event(
"DRIVER_CARD",
"CARD_POSITION",
"TACHOGRAPH:CARD_POSITION:COPY-10",
EventDomain.POSITION,
EventType.POSITION_RECORDED,
EventLifecycle.SNAPSHOT,
"10",
null,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
assertThat(service.aggregateRuntimeEvents(List.of(first, serializedCopy)))
.containsExactly(first);
}
@Test
void collapsesPlaceStartAndBeginRepresentationsForTheSamePhysicalRecord() {
String rawRecordPath = "/DriverCard/Places[1]/cardPlaceDailyWorkPeriod/placeRecords[1]";
EventHubEventDto dbStyle = event(
"DRIVER_CARD",
"CARD_PLACE",
"TACHOGRAPH:CARD_PLACE:10",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.START,
"CARDPLACE-1",
rawRecordPath,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
EventHubEventDto fileStyle = event(
"DRIVER_CARD",
"CARD_PLACE",
"TACHOGRAPH_FILE_SESSION:session-1:SUPPORT:CARDPLACE-1:BEGIN:2026-04-01T08:00:00Z",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.BEGIN,
"CARDPLACE-1",
rawRecordPath,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
assertThat(service.aggregateRuntimeEvents(List.of(dbStyle, fileStyle)))
.containsExactly(dbStyle);
}
@Test
void keepsDistinctCardPlaceRecordsWhenGeneratedIdsRepeatAcrossSections() {
String repeatedExternalId = "TACHOGRAPH_FILE_SESSION:session-1:SUPPORT:CARDPLACE-1:BEGIN:2026-04-01T08:00:00Z";
EventHubEventDto firstSection = event(
"DRIVER_CARD",
"CARD_PLACE",
repeatedExternalId,
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.BEGIN,
"CARDPLACE-1",
"/DriverCard/Places[1]/cardPlaceDailyWorkPeriod/placeRecords[1]",
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
EventHubEventDto secondSection = event(
"DRIVER_CARD",
"CARD_PLACE",
repeatedExternalId,
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.BEGIN,
"CARDPLACE-1",
"/DriverCard/Places[2]/cardPlaceDailyWorkPeriod/placeRecords[1]",
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
assertThat(service.aggregateRuntimeEvents(List.of(firstSection, secondSection)))
.containsExactly(firstSection, secondSection);
assertThat(service.exactSourceRecordKey(firstSection))
.isNotEqualTo(service.exactSourceRecordKey(secondSection));
}
@Test
void keepsSemanticallyEqualRecordsWhenTheirPhysicalSourceIdentityDiffers() {
EventHubEventDto first = event(
"DRIVER_CARD",
"CARD_PLACE",
"TACHOGRAPH_FILE_SESSION:session-1:SUPPORT:CARDPLACE-1:BEGIN:2026-04-01T08:00:00Z",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.BEGIN,
"CARDPLACE-1",
"/DriverCard/Places[1]/cardPlaceDailyWorkPeriod/placeRecords[1]",
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
EventHubEventDto second = event(
"DRIVER_CARD",
"CARD_PLACE",
"TACHOGRAPH_FILE_SESSION:session-1:SUPPORT:CARDPLACE-2:BEGIN:2026-04-01T08:00:00Z",
EventDomain.PLACE,
EventType.WORKING_DAY_PLACE_RECORDED,
EventLifecycle.BEGIN,
"CARDPLACE-2",
"/DriverCard/Places[1]/cardPlaceDailyWorkPeriod/placeRecords[2]",
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
assertThat(service.aggregateRuntimeEvents(List.of(first, second)))
.containsExactly(first, second);
}
@Test
void keepsDifferentExtractionSourcesEvenWhenSemanticEventDataIsEqual() {
EventHubEventDto card = event(
"DRIVER_CARD",
"CARD_ACTIVITY",
"TACHOGRAPH:CARD_ACTIVITY:10:START",
EventDomain.DRIVER_ACTIVITY,
EventType.DRIVE,
EventLifecycle.START,
"10",
null,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
EventHubEventDto vu = event(
"VEHICLE_UNIT",
"VU_ACTIVITY",
"TACHOGRAPH:VU_ACTIVITY:20:START",
EventDomain.DRIVER_ACTIVITY,
EventType.DRIVE,
EventLifecycle.START,
"20",
null,
OffsetDateTime.parse("2026-04-01T08:00:00Z")
);
assertThat(service.aggregateRuntimeEvents(List.of(card, vu)))
.containsExactly(card, vu);
assertThat(service.exactSourceRecordKey(card))
.isNotEqualTo(service.exactSourceRecordKey(vu));
}
private EventHubEventDto event(
String sourceKind,
String extractionCode,
String externalSourceEventId,
EventDomain domain,
EventType eventType,
EventLifecycle lifecycle,
String sourceRowId,
String rawRecordPath,
OffsetDateTime occurredAt
) {
EventSourceDto source = new EventSourceDto(
"TACHOGRAPH",
sourceKind,
"TACHOGRAPH_" + sourceKind,
null,
null,
null
);
EventHubPackageRequest packageInfo = new EventHubPackageRequest(
"default",
source,
null,
ImportScopeDto.tenantAll(null, null),
domain.name(),
null,
"TACHOGRAPH:package"
);
ObjectNode raw = JsonNodeFactory.instance.objectNode();
raw.put("sourceKind", sourceKind);
raw.put("extractionCode", extractionCode);
raw.put("driverKey", "12:123");
raw.put("registrationKey", "12:REG-1");
if (sourceRowId != null) {
raw.put("sourceRowId", sourceRowId);
}
if (rawRecordPath != null) {
raw.put("rawRecordPath", rawRecordPath);
}
ObjectNode payload = JsonNodeFactory.instance.objectNode();
payload.set("raw", raw);
return new EventHubEventDto(
null,
externalSourceEventId,
new DriverRefDto("driver-1", new DriverCardRefDto("12", "123")),
new VehicleRefDto("vehicle-1", "VIN-1", new VehicleRegistrationRefDto("12", "REG-1")),
occurredAt,
null,
null,
domain,
eventType,
lifecycle,
null,
null,
new EventDetailsDto(domain.name(), JsonNodeFactory.instance.objectNode()),
null,
payload,
false,
packageInfo
);
}
}

View File

@ -64,8 +64,14 @@ class UnifiedRuntimeEventAssemblyServiceTest {
assertThat(bundle.discoveredVehicles()).extracting(UnifiedDiscoveredVehicleRef::stableKey)
.containsExactly("SOURCE_VEHICLE:VEH-1", "VIN:VIN-2");
assertThat(bundle.expandedVehicleEvents()).hasSize(2);
assertThat(bundle.mergedEvents()).hasSize(3);
assertThat(bundle.mergedEvents()).extracting(EventHubEventDto::externalSourceEventId)
assertThat(bundle.aggregatedEvents()).hasSize(3);
assertThat(bundle.aggregatedEventCount()).isEqualTo(3);
assertThat(bundle.mergedEvents()).isSameAs(bundle.aggregatedEvents());
assertThat(bundle.notes()).anySatisfy(note -> assertThat(note)
.contains("broad aggregated runtime scope")
.contains("mixing")
.contains("reconciliation"));
assertThat(bundle.aggregatedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("SEED-1", "VEHICLE-EXPANDED", "SEED-2");
}
@ -93,7 +99,7 @@ class UnifiedRuntimeEventAssemblyServiceTest {
assertThat(bundle.driverSeedEvents()).hasSize(2);
assertThat(bundle.discoveredVehicles()).hasSize(2);
assertThat(bundle.expandedVehicleEvents()).isEmpty();
assertThat(bundle.mergedEvents()).extracting(EventHubEventDto::externalSourceEventId)
assertThat(bundle.aggregatedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("SEED-1", "SEED-2");
}
@ -119,10 +125,10 @@ class UnifiedRuntimeEventAssemblyServiceTest {
);
assertThat(bundle.driverSeedEvents()).hasSize(1);
assertThat(bundle.mergedEvents()).hasSize(1);
assertThat(bundle.mergedEvents().get(0).occurredAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:00:00Z"));
assertThat(bundle.mergedEvents().get(0).eventType()).isEqualTo(EventType.DRIVE);
assertThat(bundle.mergedEvents().get(0).lifecycle()).isEqualTo(EventLifecycle.START);
assertThat(bundle.aggregatedEvents()).hasSize(1);
assertThat(bundle.aggregatedEvents().get(0).occurredAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:00:00Z"));
assertThat(bundle.aggregatedEvents().get(0).eventType()).isEqualTo(EventType.DRIVE);
assertThat(bundle.aggregatedEvents().get(0).lifecycle()).isEqualTo(EventLifecycle.START);
}
@Test
@ -166,8 +172,8 @@ class UnifiedRuntimeEventAssemblyServiceTest {
);
assertThat(bundle.driverSeedEvents()).hasSize(3);
assertThat(bundle.mergedEvents()).hasSize(3);
assertThat(bundle.mergedEvents()).extracting(EventHubEventDto::externalSourceEventId)
assertThat(bundle.aggregatedEvents()).hasSize(3);
assertThat(bundle.aggregatedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("FILE-SESSION-1", "TACHO-DB-1", "YF-DB-1");
}
@ -231,8 +237,8 @@ class UnifiedRuntimeEventAssemblyServiceTest {
);
assertThat(bundle.driverSeedEvents()).hasSize(3);
assertThat(bundle.mergedEvents()).hasSize(3);
assertThat(bundle.mergedEvents()).extracting(EventHubEventDto::externalSourceEventId)
assertThat(bundle.aggregatedEvents()).hasSize(3);
assertThat(bundle.aggregatedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("FILE-SESSION-1", "TACHO-ACT-1", "YF-DB-1");
assertThat(bundle.notes()).anySatisfy(note -> assertThat(note).contains("mixed runtime scope"));
}