From f530b68598d7be9c40d3c8d8a28df6d18e2733ca Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Tue, 26 May 2026 17:24:32 +0200 Subject: [PATCH] Generalize runtime working-time processing pipeline --- .../DriverWorkingTimeActivityInterval.java | 120 +++ .../DriverWorkingTimeDriverPartition.java | 54 + .../model/DriverWorkingTimePreparedInput.java | 15 + .../DriverWorkingTimeProcessingInput.java | 31 + ...DriverWorkingTimeVehicleUsageInterval.java | 101 ++ .../DriverWorkingTimeProcessingCore.java | 948 +++++++++++++++++- .../RuntimeVehicleUsageIntervalDebugDto.java | 15 + .../module/DriverActivityIntervalsModule.java | 5 +- .../DriverVehicleUsageIntervalsModule.java | 5 +- .../module/DriverVehicleUsageMergeModule.java | 122 ++- ...erWorkingTimeDerivedProjectionsModule.java | 127 ++- .../SupportEvidenceNormalizationModule.java | 260 ++++- .../VehicleEvidenceAttachmentModule.java | 302 +++++- ...riverVehicleEvidenceAttachmentService.java | 158 ++- ...nifiedRuntimeDerivedProjectionService.java | 59 +- .../TachographEsperProcessingCore.java | 756 +------------- 16 files changed, 2271 insertions(+), 807 deletions(-) create mode 100644 src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeActivityInterval.java create mode 100644 src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeDriverPartition.java create mode 100644 src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimePreparedInput.java create mode 100644 src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeProcessingInput.java create mode 100644 src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeVehicleUsageInterval.java diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeActivityInterval.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeActivityInterval.java new file mode 100644 index 0000000..89cbf58 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeActivityInterval.java @@ -0,0 +1,120 @@ +package at.procon.eventhub.processing.driverworkingtime.model; + +import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record DriverWorkingTimeActivityInterval( + UUID sessionId, + String driverKey, + String intervalId, + String activityType, + String cardSlot, + String cardStatus, + String drivingStatus, + String registrationKey, + String vehicleKey, + String sourceKind, + String firstSourceIntervalId, + String lastSourceIntervalId, + OffsetDateTime startedAt, + OffsetDateTime endedAt, + long startedAtEpochSecond, + long endedAtEpochSecond, + long durationSeconds, + List sourceIntervalIds, + boolean synthetic, + boolean clippedToRequestedPeriod, + String level +) { + public DriverWorkingTimeActivityInterval { + sourceIntervalIds = sourceIntervalIds == null ? List.of() : List.copyOf(sourceIntervalIds); + } + + public static DriverWorkingTimeActivityInterval fromMap(Map values) { + if (values == null) { + return null; + } + return new DriverWorkingTimeActivityInterval( + (UUID) values.get("sessionId"), + (String) values.get("driverKey"), + (String) values.get("intervalId"), + (String) values.get("activityType"), + (String) values.get("cardSlot"), + (String) values.get("cardStatus"), + (String) values.get("drivingStatus"), + (String) values.get("registrationKey"), + (String) values.get("vehicleKey"), + (String) values.get("sourceKind"), + (String) values.get("firstSourceIntervalId"), + (String) values.get("lastSourceIntervalId"), + (OffsetDateTime) values.get("startedAt"), + (OffsetDateTime) values.get("endedAt"), + asLong(values.get("startedAtEpochSecond")), + asLong(values.get("endedAtEpochSecond")), + asLong(values.get("durationSeconds")), + castStringList(values.get("sourceIntervalIds")), + asBoolean(values.get("synthetic")), + asBoolean(values.get("clippedToRequestedPeriod")), + (String) values.get("level") + ); + } + + public static DriverWorkingTimeActivityInterval fromResolved( + UUID sessionId, + String driverKey, + ResolvedActivityInterval interval + ) { + if (interval == null || interval.from() == null || interval.to() == null) { + return null; + } + return new DriverWorkingTimeActivityInterval( + sessionId, + driverKey, + interval.intervalId(), + interval.activityType(), + interval.slot(), + interval.cardStatus(), + interval.drivingStatus(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + firstSourceIntervalId(interval), + lastSourceIntervalId(interval), + interval.from(), + interval.to(), + interval.from().toEpochSecond(), + interval.to().toEpochSecond(), + interval.durationSeconds(), + interval.sourceIntervalIds(), + interval.synthetic(), + interval.clippedToRequestedPeriod(), + interval.level() + ); + } + + private static String firstSourceIntervalId(ResolvedActivityInterval interval) { + return interval.sourceIntervalIds().isEmpty() ? interval.intervalId() : interval.sourceIntervalIds().get(0); + } + + private static String lastSourceIntervalId(ResolvedActivityInterval interval) { + return interval.sourceIntervalIds().isEmpty() + ? interval.intervalId() + : interval.sourceIntervalIds().get(interval.sourceIntervalIds().size() - 1); + } + + @SuppressWarnings("unchecked") + private static List castStringList(Object value) { + return value instanceof List list ? (List) list : List.of(); + } + + private static long asLong(Object value) { + return value instanceof Number number ? number.longValue() : 0L; + } + + private static boolean asBoolean(Object value) { + return value instanceof Boolean booleanValue && booleanValue; + } +} diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeDriverPartition.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeDriverPartition.java new file mode 100644 index 0000000..d67f4ec --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeDriverPartition.java @@ -0,0 +1,54 @@ +package at.procon.eventhub.processing.driverworkingtime.model; + +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.processing.dto.RuntimeDriverPartitionDebugDto; +import at.procon.eventhub.processing.dto.RuntimeSupportEvidenceNormalizationDebugDto; +import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceEvent; +import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef; +import java.util.List; + +public record DriverWorkingTimeDriverPartition( + String driverKey, + List driverSeedEvents, + List attachedVehicleEvidenceEvents, + List mergedEvents, + List discoveredVehicles, + List vehicleUsageIntervals, + RuntimeDriverPartitionDebugDto partitionDebug, + List supportEvidenceEvents, + RuntimeSupportEvidenceNormalizationDebugDto supportEvidenceNormalization, + List notes, + List warnings +) { + public DriverWorkingTimeDriverPartition { + driverSeedEvents = driverSeedEvents == null ? List.of() : List.copyOf(driverSeedEvents); + attachedVehicleEvidenceEvents = attachedVehicleEvidenceEvents == null ? List.of() : List.copyOf(attachedVehicleEvidenceEvents); + mergedEvents = mergedEvents == null ? List.of() : List.copyOf(mergedEvents); + discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles); + vehicleUsageIntervals = vehicleUsageIntervals == null ? List.of() : List.copyOf(vehicleUsageIntervals); + supportEvidenceEvents = supportEvidenceEvents == null ? List.of() : List.copyOf(supportEvidenceEvents); + notes = notes == null ? List.of() : List.copyOf(notes); + warnings = warnings == null ? List.of() : List.copyOf(warnings); + } + + public DriverWorkingTimeDriverPartition withSupportEvidence( + List newSupportEvidenceEvents, + RuntimeSupportEvidenceNormalizationDebugDto normalizationDebug, + List newNotes, + List newWarnings + ) { + return new DriverWorkingTimeDriverPartition( + driverKey, + driverSeedEvents, + attachedVehicleEvidenceEvents, + mergedEvents, + discoveredVehicles, + vehicleUsageIntervals, + partitionDebug, + newSupportEvidenceEvents, + normalizationDebug, + newNotes, + newWarnings + ); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimePreparedInput.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimePreparedInput.java new file mode 100644 index 0000000..0c17579 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimePreparedInput.java @@ -0,0 +1,15 @@ +package at.procon.eventhub.processing.driverworkingtime.model; + +import java.util.Objects; + +public record DriverWorkingTimePreparedInput( + String driverKey, + DriverWorkingTimeDriverPartition partition, + DriverWorkingTimeProcessingInput processingInput +) { + public DriverWorkingTimePreparedInput { + driverKey = driverKey == null || driverKey.isBlank() ? null : driverKey.trim(); + partition = Objects.requireNonNull(partition, "partition must not be null"); + processingInput = Objects.requireNonNull(processingInput, "processingInput must not be null"); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeProcessingInput.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeProcessingInput.java new file mode 100644 index 0000000..4a5a8f7 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeProcessingInput.java @@ -0,0 +1,31 @@ +package at.procon.eventhub.processing.driverworkingtime.model; + +import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceEvent; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public record DriverWorkingTimeProcessingInput( + UUID sessionId, + String driverKey, + String sourceKind, + OffsetDateTime loadedFrom, + OffsetDateTime loadedTo, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo, + int significantDrivingMinutes, + int minimumRestPeriodMinutes, + List activityIntervals, + List vehicleUsageIntervals, + List supportEvidenceEvents, + List notes +) { + public DriverWorkingTimeProcessingInput { + significantDrivingMinutes = Math.max(1, significantDrivingMinutes); + minimumRestPeriodMinutes = Math.max(1, minimumRestPeriodMinutes); + activityIntervals = activityIntervals == null ? List.of() : List.copyOf(activityIntervals); + vehicleUsageIntervals = vehicleUsageIntervals == null ? List.of() : List.copyOf(vehicleUsageIntervals); + supportEvidenceEvents = supportEvidenceEvents == null ? List.of() : List.copyOf(supportEvidenceEvents); + notes = notes == null ? List.of() : List.copyOf(notes); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeVehicleUsageInterval.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeVehicleUsageInterval.java new file mode 100644 index 0000000..7f812b4 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/model/DriverWorkingTimeVehicleUsageInterval.java @@ -0,0 +1,101 @@ +package at.procon.eventhub.processing.driverworkingtime.model; + +import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record DriverWorkingTimeVehicleUsageInterval( + UUID sessionId, + String driverKey, + String intervalId, + String firstSourceIntervalId, + String lastSourceIntervalId, + OffsetDateTime startedAt, + OffsetDateTime endedAt, + long startedAtEpochSecond, + Long endedAtEpochSecond, + long durationSeconds, + Long odometerBeginKm, + Long odometerEndKm, + String registrationKey, + String vehicleKey, + String sourceKind, + List sourceIntervalIds +) { + public DriverWorkingTimeVehicleUsageInterval { + sourceIntervalIds = sourceIntervalIds == null ? List.of() : List.copyOf(sourceIntervalIds); + } + + public static DriverWorkingTimeVehicleUsageInterval fromMap(Map values) { + if (values == null) { + return null; + } + return new DriverWorkingTimeVehicleUsageInterval( + (UUID) values.get("sessionId"), + (String) values.get("driverKey"), + (String) values.get("intervalId"), + (String) values.get("firstSourceIntervalId"), + (String) values.get("lastSourceIntervalId"), + (OffsetDateTime) values.get("startedAt"), + (OffsetDateTime) values.get("endedAt"), + asLong(values.get("startedAtEpochSecond")), + asNullableLong(values.get("endedAtEpochSecond")), + asLong(values.get("durationSeconds")), + asNullableLong(values.get("odometerBeginKm")), + asNullableLong(values.get("odometerEndKm")), + (String) values.get("registrationKey"), + (String) values.get("vehicleKey"), + (String) values.get("sourceKind"), + castStringList(values.get("sourceIntervalIds")) + ); + } + + public static DriverWorkingTimeVehicleUsageInterval fromResolved(ResolvedVehicleUsageInterval interval) { + if (interval == null || interval.from() == null) { + return null; + } + return new DriverWorkingTimeVehicleUsageInterval( + interval.sessionId(), + interval.driverKey(), + interval.intervalId(), + firstSourceIntervalId(interval), + lastSourceIntervalId(interval), + interval.from(), + interval.to(), + interval.from().toEpochSecond(), + interval.to() == null ? null : interval.to().toEpochSecond(), + interval.durationSeconds(), + interval.odometerBeginKm(), + interval.odometerEndKm(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + interval.sourceIntervalIds() + ); + } + + private static String firstSourceIntervalId(ResolvedVehicleUsageInterval interval) { + return interval.sourceIntervalIds().isEmpty() ? interval.intervalId() : interval.sourceIntervalIds().get(0); + } + + private static String lastSourceIntervalId(ResolvedVehicleUsageInterval interval) { + return interval.sourceIntervalIds().isEmpty() + ? interval.intervalId() + : interval.sourceIntervalIds().get(interval.sourceIntervalIds().size() - 1); + } + + @SuppressWarnings("unchecked") + private static List castStringList(Object value) { + return value instanceof List list ? (List) list : List.of(); + } + + private static long asLong(Object value) { + return value instanceof Number number ? number.longValue() : 0L; + } + + private static Long asNullableLong(Object value) { + return value instanceof Number number ? number.longValue() : null; + } +} diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/service/DriverWorkingTimeProcessingCore.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/service/DriverWorkingTimeProcessingCore.java index 06242ac..8da9dd2 100644 --- a/src/main/java/at/procon/eventhub/processing/driverworkingtime/service/DriverWorkingTimeProcessingCore.java +++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/service/DriverWorkingTimeProcessingCore.java @@ -1,28 +1,958 @@ package at.procon.eventhub.processing.driverworkingtime.service; +import at.procon.eventhub.config.EventHubProperties; import at.procon.eventhub.processing.driverworkingtime.dto.DriverWorkingTimeProcessingResultDto; -import at.procon.eventhub.tachographfilesession.service.TachographEsperProcessingCore; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeProcessingInput; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval; +import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceEvent; +import at.procon.eventhub.tachographfilesession.model.*; +import at.procon.eventhub.tachographfilesession.service.DriverTimelineBuilder; +import at.procon.eventhub.tachographfilesession.service.DriverTimelineReusableProjectionBuilder; import at.procon.eventhub.tachographfilesession.service.TachographEsperProcessingInput; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; import org.springframework.stereotype.Service; /** * Source-neutral driver working-time processing core. * - *

Tachograph file/database data is only one source of the canonical driver activity, - * vehicle-usage, and support-evidence event streams consumed here. The legacy - * TachographEsperProcessingCore delegates to the same processing logic for backward - * compatibility.

+ *

This core consumes canonical driver activity, vehicle-usage, and support-evidence + * timelines. Tachograph files/databases, YellowFox, and future providers should only + * contribute normalized EventHub events or reconstructed timelines; they should not own + * the working-time processing logic.

*/ @Service public class DriverWorkingTimeProcessingCore { - private final TachographEsperProcessingCore delegate; + private final DriverTimelineBuilder driverTimelineBuilder; + private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder; + private final EventHubProperties properties; - public DriverWorkingTimeProcessingCore(TachographEsperProcessingCore delegate) { - this.delegate = delegate; + public DriverWorkingTimeProcessingCore( + DriverTimelineBuilder driverTimelineBuilder, + DriverTimelineReusableProjectionBuilder reusableProjectionBuilder, + EventHubProperties properties + ) { + this.driverTimelineBuilder = driverTimelineBuilder; + this.reusableProjectionBuilder = reusableProjectionBuilder; + this.properties = properties; } public DriverWorkingTimeProcessingResultDto process(TachographEsperProcessingInput input) { - return delegate.processDriverWorkingTime(input); + Objects.requireNonNull(input, "input must not be null"); + return process(toSourceNeutralInput(input)); + } + + public DriverWorkingTimeProcessingResultDto process(DriverWorkingTimeProcessingInput input) { + Objects.requireNonNull(input, "input must not be null"); + ResolvedDriverTimeline timeline = toResolvedTimeline(input); + String driverKey = input.driverKey(); + OffsetDateTime requestedFrom = input.requestedFrom() == null ? timeline.loadedFrom() : utc(input.requestedFrom()); + OffsetDateTime requestedTo = input.requestedTo() == null ? timeline.loadedTo() : utc(input.requestedTo()); + if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) { + throw new IllegalArgumentException("occurredTo must not be before occurredFrom."); + } + + List activityIntervals = clipEsperActivityIntervalEvents( + driverTimelineBuilder.buildEsperActivityIntervalEvents(input.sessionId(), driverKey, timeline), + requestedFrom, + requestedTo + ); + List drivingIntervals = clipEsperActivityIntervalEvents( + driverTimelineBuilder.buildEsperDrivingIntervalEvents(input.sessionId(), driverKey, timeline), + requestedFrom, + requestedTo + ); + + TachographEsperDrivingDerivedProjectionBundle derivedProjectionBundle = + reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle( + safeSessionId(input.sessionId()), + driverKey, + timeline, + input.significantDrivingMinutes(), + input.minimumRestPeriodMinutes() + ); + + List rawDrivingInterruptionIntervals = + derivedProjectionBundle.drivingInterruptionIntervals(); + List drivingInterruptionIntervals = + clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionIntervals, requestedFrom, requestedTo); + List rawDailyWeeklyRestCandidateIntervals = + derivedProjectionBundle.dailyWeeklyRestCandidateIntervals(); + List dailyWeeklyRestCandidateIntervals = + clipEsperDrivingInterruptionIntervalEvents(rawDailyWeeklyRestCandidateIntervals, requestedFrom, requestedTo); + List rawDrivingInterruptionVehicleChangeIntervals = + derivedProjectionBundle.drivingInterruptionVehicleChangeIntervals(); + List drivingInterruptionVehicleChangeIntervals = + clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionVehicleChangeIntervals, requestedFrom, requestedTo); + + List rawVehicleUsageIntervals = + driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline); + List rawVuCardAbsentIntervals = + derivedProjectionBundle.vuCardAbsentIntervals(); + + List potentialHomeOvernightStayIntervals = + clipEsperPotentialHomeOvernightStayIntervalEvents( + derivedProjectionBundle.potentialHomeOvernightStayIntervals(), + rawVuCardAbsentIntervals, + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List dailyWeeklyRestCandidateCoverageIntervals = + clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( + derivedProjectionBundle.dailyWeeklyRestCandidateCoverageIntervals(), + rawVuCardAbsentIntervals, + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List unclassifiedDailyWeeklyRestCandidateCoverageIntervals = + clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( + derivedProjectionBundle.unclassifiedDailyWeeklyRestCandidateCoverageIntervals(), + rawVuCardAbsentIntervals, + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List potentialInVehicleOvernightStayIntervals = + clipEsperPotentialInVehicleOvernightStayIntervalEvents( + derivedProjectionBundle.potentialInVehicleOvernightStayIntervals(), + rawVuCardAbsentIntervals, + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List potentialInVehicleTripIntervals = + clipEsperPotentialInVehicleTripIntervalEvents( + derivedProjectionBundle.potentialInVehicleTripIntervals(), + potentialInVehicleOvernightStayIntervals, + requestedFrom, + requestedTo + ); + List vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents( + rawVehicleUsageIntervals, + requestedFrom, + requestedTo + ); + List vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents( + rawVuCardAbsentIntervals, + requestedFrom, + requestedTo + ); + List supportGeoEvents = clipEsperSupportGeoEvents( + timeline.supportEvents(), + driverKey, + requestedFrom, + requestedTo + ); + + return new DriverWorkingTimeProcessingResultDto( + input.sessionId(), + driverKey, + timeline.sourceKind(), + timeline.loadedFrom(), + timeline.loadedTo(), + requestedFrom, + requestedTo, + activityIntervals.size(), + drivingIntervals.size(), + drivingInterruptionIntervals.size(), + drivingInterruptionVehicleChangeIntervals.size(), + dailyWeeklyRestCandidateIntervals.size(), + dailyWeeklyRestCandidateCoverageIntervals.size(), + unclassifiedDailyWeeklyRestCandidateCoverageIntervals.size(), + potentialHomeOvernightStayIntervals.size(), + potentialInVehicleOvernightStayIntervals.size(), + potentialInVehicleTripIntervals.size(), + vehicleUsageIntervals.size(), + vuCardAbsentIntervals.size(), + supportGeoEvents.size(), + activityIntervals, + drivingIntervals, + drivingInterruptionIntervals, + drivingInterruptionVehicleChangeIntervals, + dailyWeeklyRestCandidateIntervals, + dailyWeeklyRestCandidateCoverageIntervals, + unclassifiedDailyWeeklyRestCandidateCoverageIntervals, + potentialHomeOvernightStayIntervals, + potentialInVehicleOvernightStayIntervals, + potentialInVehicleTripIntervals, + vehicleUsageIntervals, + vuCardAbsentIntervals, + supportGeoEvents, + combinedNotes(input.notes()) + ); + } + + public DriverWorkingTimeProcessingResultDto processDriverWorkingTime(TachographEsperProcessingInput input) { + return process(input); + } + + private DriverWorkingTimeProcessingInput toSourceNeutralInput(TachographEsperProcessingInput input) { + ResolvedDriverTimeline timeline = Objects.requireNonNull(input.timeline(), "timeline must not be null"); + String driverKey = input.driverKey(); + return new DriverWorkingTimeProcessingInput( + input.sessionId(), + driverKey, + timeline.sourceKind(), + timeline.loadedFrom(), + timeline.loadedTo(), + input.requestedFrom(), + input.requestedTo(), + input.significantDrivingMinutes(), + input.minimumRestPeriodMinutes(), + safeList(timeline.activityIntervals()).stream() + .map(interval -> DriverWorkingTimeActivityInterval.fromResolved(input.sessionId(), driverKey, interval)) + .filter(Objects::nonNull) + .toList(), + safeList(timeline.vehicleUsageIntervals()).stream() + .map(DriverWorkingTimeVehicleUsageInterval::fromResolved) + .filter(Objects::nonNull) + .toList(), + safeList(timeline.supportEvents()).stream() + .map(this::toSupportEvidenceEvent) + .filter(Objects::nonNull) + .toList(), + input.notes() + ); + } + + private ResolvedDriverTimeline toResolvedTimeline(DriverWorkingTimeProcessingInput input) { + List activityIntervals = input.activityIntervals().stream() + .map(this::toResolvedActivityInterval) + .filter(Objects::nonNull) + .toList(); + List vehicleUsageIntervals = input.vehicleUsageIntervals().stream() + .map(this::toResolvedVehicleUsageInterval) + .filter(Objects::nonNull) + .toList(); + List supportEvents = input.supportEvidenceEvents().stream() + .map(this::toExtractedSupportEvent) + .filter(Objects::nonNull) + .toList(); + OffsetDateTime loadedFrom = input.loadedFrom() == null + ? earliest(activityIntervals, vehicleUsageIntervals, supportEvents) + : utc(input.loadedFrom()); + OffsetDateTime loadedTo = input.loadedTo() == null + ? latest(activityIntervals, vehicleUsageIntervals, supportEvents) + : utc(input.loadedTo()); + return new ResolvedDriverTimeline( + input.sourceKind(), + loadedFrom, + loadedTo, + vehicleUsageIntervals, + activityIntervals, + supportEvents, + List.of() + ); + } + + private ResolvedActivityInterval toResolvedActivityInterval(DriverWorkingTimeActivityInterval interval) { + if (interval == null || interval.startedAt() == null || interval.endedAt() == null) { + return null; + } + return new ResolvedActivityInterval( + interval.intervalId(), + utc(interval.startedAt()), + utc(interval.endedAt()), + interval.durationSeconds(), + interval.activityType(), + interval.cardSlot(), + interval.cardStatus(), + interval.drivingStatus(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + interval.sourceIntervalIds(), + interval.synthetic(), + interval.clippedToRequestedPeriod(), + interval.level() + ); + } + + private ResolvedVehicleUsageInterval toResolvedVehicleUsageInterval(DriverWorkingTimeVehicleUsageInterval interval) { + if (interval == null || interval.startedAt() == null) { + return null; + } + return new ResolvedVehicleUsageInterval( + safeSessionId(interval.sessionId()), + interval.driverKey(), + interval.intervalId(), + utc(interval.startedAt()), + utc(interval.endedAt()), + interval.durationSeconds(), + interval.odometerBeginKm(), + interval.odometerEndKm(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + interval.sourceIntervalIds() + ); + } + + private RuntimeSupportEvidenceEvent toSupportEvidenceEvent(ExtractedSupportEvent supportEvent) { + if (supportEvent == null || supportEvent.occurredAt() == null) { + return null; + } + return new RuntimeSupportEvidenceEvent( + supportEvent.eventId(), + null, + null, + supportEvent.eventDomain(), + supportEvent.eventType(), + supportEvent.eventLifecycle(), + supportEvent.driverKey(), + supportEvent.vehicleKey(), + supportEvent.registrationKey(), + utc(supportEvent.occurredAt()), + supportEvent.occurredAt().toEpochSecond(), + supportEvent.latitude(), + supportEvent.longitude(), + supportEvent.country(), + supportEvent.region(), + supportEvent.countryFrom(), + supportEvent.countryTo(), + supportEvent.operation(), + supportEvent.odometerKm(), + supportEvent.avgSpeedKmh(), + supportEvent.maxSpeedKmh(), + Map.of("rawRecordPath", supportEvent.rawRecordPath()) + ); + } + + private ExtractedSupportEvent toExtractedSupportEvent(RuntimeSupportEvidenceEvent supportEvent) { + if (supportEvent == null || supportEvent.occurredAt() == null) { + return null; + } + Object rawRecordPath = supportEvent.rawAttributes().get("rawRecordPath"); + return new ExtractedSupportEvent( + supportEvent.eventId(), + supportEvent.driverKey(), + utc(supportEvent.occurredAt()), + supportEvent.eventDomain(), + supportEvent.eventType(), + supportEvent.lifecycle(), + null, + supportEvent.registrationKey(), + supportEvent.vehicleKey(), + supportEvent.countryCode(), + supportEvent.regionCode(), + supportEvent.countryFrom(), + supportEvent.countryTo(), + supportEvent.operation(), + supportEvent.latitude(), + supportEvent.longitude(), + null, + supportEvent.odometerKm(), + null, + supportEvent.speedKmh(), + supportEvent.maxSpeedKmh(), + rawRecordPath == null ? null : rawRecordPath.toString() + ); + } + + private OffsetDateTime earliest( + List activityIntervals, + List vehicleUsageIntervals, + List supportEvents + ) { + OffsetDateTime earliest = null; + for (ResolvedActivityInterval interval : activityIntervals) { + earliest = min(earliest, interval.from()); + } + for (ResolvedVehicleUsageInterval interval : vehicleUsageIntervals) { + earliest = min(earliest, interval.from()); + } + for (ExtractedSupportEvent event : supportEvents) { + earliest = min(earliest, event.occurredAt()); + } + return earliest; + } + + private OffsetDateTime latest( + List activityIntervals, + List vehicleUsageIntervals, + List supportEvents + ) { + OffsetDateTime latest = null; + for (ResolvedActivityInterval interval : activityIntervals) { + latest = max(latest, interval.to()); + } + for (ResolvedVehicleUsageInterval interval : vehicleUsageIntervals) { + latest = max(latest, interval.to()); + } + for (ExtractedSupportEvent event : supportEvents) { + latest = max(latest, event.occurredAt()); + } + return latest; + } + + private UUID safeSessionId(UUID sessionId) { + return sessionId == null ? new UUID(0L, 0L) : sessionId; + } + + private List safeList(List values) { + return values == null ? List.of() : values; + } + + private List combinedNotes(List extraNotes) { + List notes = new ArrayList<>(); + notes.addAll(esperProjectionNotes()); + if (extraNotes != null) { + notes.addAll(extraNotes); + } + return List.copyOf(notes); + } + + + private List clipEsperActivityIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + boolean clipped = interval.clippedToRequestedPeriod() + || !start.equals(interval.startedAt()) + || !end.equals(interval.endedAt()); + return new TachographEsperActivityIntervalEvent( + interval.sessionId(), + interval.driverKey(), + interval.intervalId(), + interval.activityType(), + interval.cardSlot(), + interval.cardStatus(), + interval.drivingStatus(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.sourceIntervalIds(), + interval.synthetic(), + clipped, + interval.level() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperActivityIntervalEvent::startedAt) + .thenComparing(TachographEsperActivityIntervalEvent::endedAt) + .thenComparing(TachographEsperActivityIntervalEvent::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List clipEsperVehicleUsageIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + boolean startClipped = !start.equals(interval.startedAt()); + boolean endClipped = !end.equals(interval.endedAt()); + return new TachographEsperVehicleUsageIntervalEvent( + interval.sessionId(), + interval.driverKey(), + interval.intervalId(), + start, + end, + Duration.between(start, end).getSeconds(), + startClipped ? null : interval.odometerBeginKm(), + endClipped ? null : interval.odometerEndKm(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + interval.sourceIntervalIds() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt) + .thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt) + .thenComparing(TachographEsperVehicleUsageIntervalEvent::intervalId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List clipEsperSupportGeoEvents( + List supportEvents, + String driverKey, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (supportEvents == null || supportEvents.isEmpty() || requestedFrom == null || requestedTo == null) { + return List.of(); + } + return supportEvents.stream() + .filter(event -> event.driverKey() == null || Objects.equals(driverKey, event.driverKey())) + .filter(event -> event.occurredAt() != null) + .filter(event -> event.latitude() != null && event.longitude() != null) + .filter(event -> !event.occurredAt().isBefore(requestedFrom) && !event.occurredAt().isAfter(requestedTo)) + .map(event -> new TachographEsperSupportGeoEvent( + event.eventId(), + event.driverKey(), + event.occurredAt(), + event.eventDomain(), + event.eventType(), + event.eventLifecycle(), + event.registrationKey(), + event.vehicleKey(), + event.country(), + event.region(), + event.countryFrom(), + event.countryTo(), + event.operation(), + event.latitude(), + event.longitude(), + event.odometerKm(), + event.rawRecordPath() + )) + .sorted(Comparator.comparing(TachographEsperSupportGeoEvent::occurredAt) + .thenComparing(TachographEsperSupportGeoEvent::eventDomain, Comparator.nullsLast(String::compareTo)) + .thenComparing(TachographEsperSupportGeoEvent::eventId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List clipEsperDrivingInterruptionIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + return new TachographEsperDrivingInterruptionIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.previousDrivingSourceIntervalId(), + interval.nextDrivingSourceIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperDrivingInterruptionIntervalEvent::startedAt) + .thenComparing(TachographEsperDrivingInterruptionIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperVuCardAbsentIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + return new TachographEsperVuCardAbsentIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.previousUsageIntervalId(), + interval.nextUsageIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperVuCardAbsentIntervalEvent::startedAt) + .thenComparing(TachographEsperVuCardAbsentIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( + List intervals, + List rawVuCardAbsentIntervals, + List rawVehicleUsageIntervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + long durationSeconds = Duration.between(start, end).getSeconds(); + boolean beginBoundaryChanged = !start.equals(interval.startedAt()); + boolean endBoundaryChanged = !end.equals(interval.endedAt()); + return new TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + durationSeconds, + interval.cardAbsentDurationSeconds(), + interval.cardAbsentCoveragePercent(), + interval.previousDrivingSourceIntervalId(), + interval.nextDrivingSourceIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey(), + beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(), + endBoundaryChanged ? null : interval.endBoundaryOdometerKm(), + beginBoundaryChanged ? null : interval.beginGeoEventId(), + beginBoundaryChanged ? null : interval.beginGeoEventDomain(), + beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), + beginBoundaryChanged ? null : interval.beginLatitude(), + beginBoundaryChanged ? null : interval.beginLongitude(), + beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(), + beginBoundaryChanged ? null : interval.beginGeoOdometerKm(), + endBoundaryChanged ? null : interval.endGeoEventId(), + endBoundaryChanged ? null : interval.endGeoEventDomain(), + endBoundaryChanged ? null : interval.endGeoOccurredAt(), + endBoundaryChanged ? null : interval.endLatitude(), + endBoundaryChanged ? null : interval.endLongitude(), + endBoundaryChanged ? null : interval.endGeoDistanceSeconds(), + endBoundaryChanged ? null : interval.endGeoOdometerKm(), + beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(), + beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(), + beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()), + endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm()) + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::startedAt) + .thenComparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperPotentialHomeOvernightStayIntervalEvents( + List intervals, + List rawVuCardAbsentIntervals, + List rawVehicleUsageIntervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + long durationSeconds = Duration.between(start, end).getSeconds(); + boolean beginBoundaryChanged = !start.equals(interval.startedAt()); + boolean endBoundaryChanged = !end.equals(interval.endedAt()); + return new TachographEsperPotentialHomeOvernightStayIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + durationSeconds, + interval.cardAbsentDurationSeconds(), + interval.cardAbsentCoveragePercent(), + interval.previousDrivingSourceIntervalId(), + interval.nextDrivingSourceIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey(), + beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(), + endBoundaryChanged ? null : interval.endBoundaryOdometerKm(), + beginBoundaryChanged ? null : interval.beginGeoEventId(), + beginBoundaryChanged ? null : interval.beginGeoEventDomain(), + beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), + beginBoundaryChanged ? null : interval.beginLatitude(), + beginBoundaryChanged ? null : interval.beginLongitude(), + beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(), + beginBoundaryChanged ? null : interval.beginGeoOdometerKm(), + endBoundaryChanged ? null : interval.endGeoEventId(), + endBoundaryChanged ? null : interval.endGeoEventDomain(), + endBoundaryChanged ? null : interval.endGeoOccurredAt(), + endBoundaryChanged ? null : interval.endLatitude(), + endBoundaryChanged ? null : interval.endLongitude(), + endBoundaryChanged ? null : interval.endGeoDistanceSeconds(), + endBoundaryChanged ? null : interval.endGeoOdometerKm(), + beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(), + beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(), + beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()), + endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm()) + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::startedAt) + .thenComparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperPotentialInVehicleOvernightStayIntervalEvents( + List intervals, + List rawVuCardAbsentIntervals, + List rawVehicleUsageIntervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + long durationSeconds = Duration.between(start, end).getSeconds(); + boolean beginBoundaryChanged = !start.equals(interval.startedAt()); + boolean endBoundaryChanged = !end.equals(interval.endedAt()); + return new TachographEsperPotentialInVehicleOvernightStayIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + durationSeconds, + interval.cardAbsentDurationSeconds(), + interval.cardAbsentCoveragePercent(), + interval.previousDrivingSourceIntervalId(), + interval.nextDrivingSourceIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey(), + beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(), + endBoundaryChanged ? null : interval.endBoundaryOdometerKm(), + beginBoundaryChanged ? null : interval.beginGeoEventId(), + beginBoundaryChanged ? null : interval.beginGeoEventDomain(), + beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), + beginBoundaryChanged ? null : interval.beginLatitude(), + beginBoundaryChanged ? null : interval.beginLongitude(), + beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(), + beginBoundaryChanged ? null : interval.beginGeoOdometerKm(), + endBoundaryChanged ? null : interval.endGeoEventId(), + endBoundaryChanged ? null : interval.endGeoEventDomain(), + endBoundaryChanged ? null : interval.endGeoOccurredAt(), + endBoundaryChanged ? null : interval.endLatitude(), + endBoundaryChanged ? null : interval.endLongitude(), + endBoundaryChanged ? null : interval.endGeoDistanceSeconds(), + endBoundaryChanged ? null : interval.endGeoOdometerKm(), + beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(), + beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(), + beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()), + endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm()) + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt) + .thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperPotentialInVehicleTripIntervalEvents( + List intervals, + List potentialInVehicleOvernightStayIntervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + if (intervals == null || intervals.isEmpty() + || potentialInVehicleOvernightStayIntervals == null || potentialInVehicleOvernightStayIntervals.isEmpty()) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + List containedIntervals = + potentialInVehicleOvernightStayIntervals.stream() + .filter(candidate -> tripContainsPotentialInterval( + interval.driverKey(), + interval.registrationKey(), + interval.vehicleKey(), + start, + end, + candidate + )) + .sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt) + .thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt)) + .toList(); + if (containedIntervals.isEmpty()) { + return null; + } + TachographEsperPotentialInVehicleOvernightStayIntervalEvent first = containedIntervals.get(0); + TachographEsperPotentialInVehicleOvernightStayIntervalEvent last = + containedIntervals.get(containedIntervals.size() - 1); + return new TachographEsperPotentialInVehicleTripIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.registrationKey(), + interval.vehicleKey(), + containedIntervals.size(), + containedIntervals.stream() + .mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::durationSeconds) + .sum(), + containedIntervals.stream() + .mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::cardAbsentDurationSeconds) + .sum(), + first.startedAt(), + last.endedAt(), + first.previousDrivingSourceIntervalId(), + last.nextDrivingSourceIntervalId(), + containedIntervals + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperPotentialInVehicleTripIntervalEvent::startedAt) + .thenComparing(TachographEsperPotentialInVehicleTripIntervalEvent::endedAt)) + .toList(); + } + + private boolean tripContainsPotentialInterval( + String driverKey, + String registrationKey, + String vehicleKey, + OffsetDateTime tripStartedAt, + OffsetDateTime tripEndedAt, + TachographEsperPotentialInVehicleOvernightStayIntervalEvent candidate + ) { + if (!Objects.equals(driverKey, candidate.driverKey())) { + return false; + } + if (!Objects.equals(registrationKey, candidate.previousRegistrationKey())) { + return false; + } + if (vehicleKey != null && candidate.previousVehicleKey() != null + && !Objects.equals(vehicleKey, candidate.previousVehicleKey())) { + return false; + } + return !candidate.startedAt().isBefore(tripStartedAt) + && !candidate.endedAt().isAfter(tripEndedAt); + } + + + private List esperProjectionNotes() { + return List.of( + "This endpoint returns Esper-backed per-driver interval projections from the in-memory tachograph file-session model.", + "Driving intervals are a filtered projection of activity intervals where activityType = DRIVE.", + "Driving interruption intervals are gaps between consecutive driving intervals longer than the configured significant-driving threshold.", + "Driving interruption vehicle-change intervals are daily/weekly rest candidates where previousRegistrationKey differs from nextRegistrationKey.", + "Daily/weekly rest candidate intervals are driving interruption intervals longer than the configured minimum rest-period threshold.", + "Daily/weekly rest candidate coverage intervals enrich each rest candidate with card-present and card-absent coverage metrics computed from vehicle-usage and VU card-absent overlap.", + "Daily/weekly rest candidate coverage intervals also attach begin/end geo evidence from nearby support events for the same driver and boundary-side vehicle identity.", + "Boundary geo evidence prefers the nearest matching POSITION event, then PLACE, BORDER_CROSSING, and LOAD_UNLOAD within the configured lookback/lookahead windows.", + "If both begin and end geo evidence carry odometer values, geoEvidenceMovementCategory classifies the interval as STATIONARY, MINOR, MOVED, or UNKNOWN.", + "Unclassified daily/weekly rest candidate coverage intervals are the rest candidates that are neither potential home overnight stays nor potential in-vehicle overnight stays.", + "Potential home overnight stay intervals are vehicle-change daily/weekly rest candidate coverage intervals where VU card-absent overlap covers at least 95% of the candidate interval.", + "Potential in-vehicle overnight stay intervals are no-change daily/weekly rest candidate coverage intervals where card-present overlap covers the candidate rest period.", + "Potential in-vehicle trip intervals span from the end of the coverage interval before a same-vehicle in-vehicle-overnight sequence to the start of the first coverage interval after that sequence.", + "VU card-absent intervals are gaps between consecutive normalized vehicle-usage intervals for the same driver.", + "occurredFrom and occurredTo clip the returned interval projections to the requested UTC time window.", + "Vehicle-usage intervals clear clipped odometer endpoints because boundary odometer values cannot be recomputed safely from the source interval." + ); + } + + private OffsetDateTime utc(OffsetDateTime value) { + return value == null ? null : value.withOffsetSameInstant(java.time.ZoneOffset.UTC); + } + + private TachographEsperGeoEvidenceEvent geoEvidenceEvent( + String eventId, + String eventDomain, + OffsetDateTime occurredAt, + Double latitude, + Double longitude, + Long distanceSeconds, + Long odometerKm + ) { + if (eventId == null + && eventDomain == null + && occurredAt == null + && latitude == null + && longitude == null + && distanceSeconds == null + && odometerKm == null) { + return null; + } + return new TachographEsperGeoEvidenceEvent( + eventId, + eventDomain, + occurredAt, + latitude, + longitude, + distanceSeconds, + odometerKm + ); + } + + private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isAfter(right) ? left : right; + } + + private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isBefore(right) ? left : right; } } diff --git a/src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleUsageIntervalDebugDto.java b/src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleUsageIntervalDebugDto.java index 34fbeff..e29dfad 100644 --- a/src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleUsageIntervalDebugDto.java +++ b/src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleUsageIntervalDebugDto.java @@ -1,5 +1,6 @@ package at.procon.eventhub.processing.dto; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval; import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval; import java.time.OffsetDateTime; @@ -24,4 +25,18 @@ public record RuntimeVehicleUsageIntervalDebugDto( interval.sourceKind() ); } + + public static RuntimeVehicleUsageIntervalDebugDto from(DriverWorkingTimeVehicleUsageInterval interval) { + if (interval == null) { + return null; + } + return new RuntimeVehicleUsageIntervalDebugDto( + interval.intervalId(), + interval.startedAt(), + interval.endedAt(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind() + ); + } } diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverActivityIntervalsModule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverActivityIntervalsModule.java index 7570250..d46ad68 100644 --- a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverActivityIntervalsModule.java +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverActivityIntervalsModule.java @@ -1,6 +1,7 @@ package at.procon.eventhub.processing.eventprocessing.module; import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval; import at.procon.eventhub.processing.eventprocessing.module.epl.DriverWorkingTimeEplEventMapper; import at.procon.eventhub.processing.eventprocessing.module.epl.RuntimeEplInputEventStream; import at.procon.eventhub.processing.eventprocessing.module.epl.RuntimeEplModule; @@ -66,7 +67,9 @@ public class DriverActivityIntervalsModule implements RuntimeEplModule { pointEvents )) )); - List> intervals = eplResult.output(OUTPUT_STATEMENT); + List intervals = eplResult.output(OUTPUT_STATEMENT).stream() + .map(DriverWorkingTimeActivityInterval::fromMap) + .toList(); Map metadata = new LinkedHashMap<>(eplResult.metadata()); metadata.put("inputEventCount", sourceEvents.size()); metadata.put("activityPointEventCount", pointEvents.size()); diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageIntervalsModule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageIntervalsModule.java index d34ebaa..5a26093 100644 --- a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageIntervalsModule.java +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageIntervalsModule.java @@ -1,6 +1,7 @@ package at.procon.eventhub.processing.eventprocessing.module; import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval; import at.procon.eventhub.processing.eventprocessing.module.epl.DriverWorkingTimeEplEventMapper; import at.procon.eventhub.processing.eventprocessing.module.epl.RuntimeEplInputEventStream; import at.procon.eventhub.processing.eventprocessing.module.epl.RuntimeEplModule; @@ -66,7 +67,9 @@ public class DriverVehicleUsageIntervalsModule implements RuntimeEplModule { pointEvents )) )); - List> intervals = eplResult.output(OUTPUT_STATEMENT); + List intervals = eplResult.output(OUTPUT_STATEMENT).stream() + .map(DriverWorkingTimeVehicleUsageInterval::fromMap) + .toList(); Map metadata = new LinkedHashMap<>(eplResult.metadata()); metadata.put("inputEventCount", sourceEvents.size()); metadata.put("vehicleUsagePointEventCount", pointEvents.size()); diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageMergeModule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageMergeModule.java index 192c3e6..b7c54c1 100644 --- a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageMergeModule.java +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageMergeModule.java @@ -1,18 +1,128 @@ package at.procon.eventhub.processing.eventprocessing.module; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval; +import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingModuleDescriptorDto; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; import org.springframework.stereotype.Component; @Component -public class DriverVehicleUsageMergeModule extends AbstractDriverWorkingTimePhaseModule { +public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule { - public DriverVehicleUsageMergeModule() { - super( - DriverWorkingTimeModuleKeys.VEHICLE_USAGE_MERGE, + @Override + public String moduleKey() { + return DriverWorkingTimeModuleKeys.VEHICLE_USAGE_MERGE; + } + + @Override + public RuntimeProcessingModuleDescriptorDto descriptor() { + return new RuntimeProcessingModuleDescriptorDto( + moduleKey(), "Vehicle usage merge", "Merges adjacent or continuous same-driver/same-vehicle usage intervals, including 23:59:59 to 00:00:00 continuations.", - "JAVA/ESPER", - Set.of("DriverVehicleUsageIntervalInputEvent") + "JAVA", + Set.of("DriverWorkingTimeVehicleUsageInterval") + ); + } + + @Override + public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) { + Object output = context.previousResults().get(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS) == null + ? null + : context.previousResults().get(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS).output(); + List intervals = castIntervals(output); + List merged = merge(intervals); + Map metadata = new LinkedHashMap<>(); + metadata.put("inputIntervalCount", intervals.size()); + metadata.put("mergedIntervalCount", merged.size()); + return new RuntimeProcessingModuleResult( + moduleKey(), + RuntimeProcessingModuleStatus.SUCCESS, + merged, + metadata, + List.of() + ); + } + + @SuppressWarnings("unchecked") + private List castIntervals(Object output) { + return output instanceof List list + ? (List) list + : List.of(); + } + + private List merge(List intervals) { + if (intervals == null || intervals.isEmpty()) { + return List.of(); + } + List sorted = intervals.stream() + .sorted(Comparator.comparing(DriverWorkingTimeVehicleUsageInterval::startedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(DriverWorkingTimeVehicleUsageInterval::endedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(DriverWorkingTimeVehicleUsageInterval::intervalId, Comparator.nullsLast(String::compareTo))) + .toList(); + List merged = new ArrayList<>(); + for (DriverWorkingTimeVehicleUsageInterval next : sorted) { + if (merged.isEmpty()) { + merged.add(next); + continue; + } + DriverWorkingTimeVehicleUsageInterval current = merged.get(merged.size() - 1); + if (canMerge(current, next)) { + merged.set(merged.size() - 1, merge(current, next)); + } else { + merged.add(next); + } + } + return List.copyOf(merged); + } + + private boolean canMerge( + DriverWorkingTimeVehicleUsageInterval left, + DriverWorkingTimeVehicleUsageInterval right + ) { + if (left == null || right == null || left.endedAt() == null || right.startedAt() == null) { + return false; + } + return Objects.equals(left.driverKey(), right.driverKey()) + && Objects.equals(left.registrationKey(), right.registrationKey()) + && Objects.equals(left.vehicleKey(), right.vehicleKey()) + && !right.startedAt().isAfter(left.endedAt().plusSeconds(1)); + } + + private DriverWorkingTimeVehicleUsageInterval merge( + DriverWorkingTimeVehicleUsageInterval left, + DriverWorkingTimeVehicleUsageInterval right + ) { + LinkedHashSet sourceIntervalIds = new LinkedHashSet<>(left.sourceIntervalIds()); + sourceIntervalIds.addAll(right.sourceIntervalIds()); + OffsetDateTime mergedEnd = left.endedAt(); + if (right.endedAt() != null && (mergedEnd == null || right.endedAt().isAfter(mergedEnd))) { + mergedEnd = right.endedAt(); + } + return new DriverWorkingTimeVehicleUsageInterval( + left.sessionId(), + left.driverKey(), + left.intervalId(), + left.firstSourceIntervalId(), + right.lastSourceIntervalId() == null ? left.lastSourceIntervalId() : right.lastSourceIntervalId(), + left.startedAt(), + mergedEnd, + left.startedAtEpochSecond(), + mergedEnd == null ? null : mergedEnd.toEpochSecond(), + mergedEnd == null ? left.durationSeconds() : mergedEnd.toEpochSecond() - left.startedAtEpochSecond(), + left.odometerBeginKm(), + right.odometerEndKm() == null ? left.odometerEndKm() : right.odometerEndKm(), + left.registrationKey(), + left.vehicleKey(), + left.sourceKind(), + List.copyOf(sourceIntervalIds) ); } } diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeDerivedProjectionsModule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeDerivedProjectionsModule.java index dc00bba..74f22bf 100644 --- a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeDerivedProjectionsModule.java +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeDerivedProjectionsModule.java @@ -1,23 +1,42 @@ package at.procon.eventhub.processing.eventprocessing.module; +import at.procon.eventhub.processing.driverworkingtime.dto.DriverWorkingTimeProcessingResultDto; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput; +import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto; import at.procon.eventhub.processing.dto.UnifiedRuntimeDriverWorkingTimeScopeResultDto; import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingModuleDescriptorDto; +import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; import at.procon.eventhub.processing.service.RuntimeDriverWorkingTimeScopeProcessingService; +import at.procon.eventhub.processing.driverworkingtime.service.DriverWorkingTimeProcessingCore; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcessingModule { - private final RuntimeDriverWorkingTimeScopeProcessingService scopeProcessingService; + private final DriverWorkingTimeProcessingCore workingTimeProcessingCore; + private final RuntimeDriverWorkingTimeScopeProcessingService legacyScopeProcessingService; + + @Autowired + public DriverWorkingTimeDerivedProjectionsModule( + DriverWorkingTimeProcessingCore workingTimeProcessingCore + ) { + this.workingTimeProcessingCore = workingTimeProcessingCore; + this.legacyScopeProcessingService = null; + } public DriverWorkingTimeDerivedProjectionsModule( RuntimeDriverWorkingTimeScopeProcessingService scopeProcessingService ) { - this.scopeProcessingService = scopeProcessingService; + this.workingTimeProcessingCore = null; + this.legacyScopeProcessingService = scopeProcessingService; } @Override @@ -30,21 +49,58 @@ public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcess return new RuntimeProcessingModuleDescriptorDto( moduleKey(), "Driving-derived projections", - "Executes the shared driver working-time pipeline for driving interruptions, rest candidates, card-absence coverage, overnight candidates, and trip candidates.", + "Executes the shared driver working-time core from typed per-driver module outputs for driving interruptions, rest candidates, card-absence coverage, overnight candidates, and trip candidates.", "ESPER+JAVA", - Set.of("DriverWorkingTimeProcessingResultDto") + Set.of("UnifiedRuntimeDriverWorkingTimeScopeResultDto") ); } @Override public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) { + if (legacyScopeProcessingService != null) { + return executeLegacy(context); + } + UnifiedRuntimeEventBundle broadBundle = runtimeEventBundle(context); UnifiedRuntimeProcessingApiRequest scopeRequest = scopeRequest(context); - boolean includePartitionDebug = booleanAttribute(context, "includePartitionDebug", false); - UnifiedRuntimeDriverWorkingTimeScopeResultDto result = scopeProcessingService.processScope( - scopeRequest, - includePartitionDebug - ); + Map preparedInputs = preparedInputs(context); + LinkedHashMap driverResults = new LinkedHashMap<>(); + List warnings = new ArrayList<>(); + for (Map.Entry entry : preparedInputs.entrySet()) { + DriverWorkingTimePreparedInput preparedInput = entry.getValue(); + DriverWorkingTimeProcessingResultDto projection = + workingTimeProcessingCore.process(preparedInput.processingInput()); + warnings.addAll(preparedInput.partition().warnings()); + UnifiedRuntimeProcessingRequest driverRequest = broadBundle.request().withDriverKey(preparedInput.driverKey()); + driverResults.put(preparedInput.driverKey(), new UnifiedRuntimeDerivedProjectionResultDto( + driverRequest, + preparedInput.partition().driverSeedEvents().size(), + preparedInput.partition().discoveredVehicles().size(), + preparedInput.partition().attachedVehicleEvidenceEvents().size(), + preparedInput.partition().mergedEvents().size(), + preparedInput.partition().discoveredVehicles(), + projection, + projection.notes(), + preparedInput.partition().supportEvidenceNormalization(), + preparedInput.partition().partitionDebug() + )); + } + + List notes = new ArrayList<>(broadBundle.notes()); + notes.add("Runtime driver working-time processing used module-to-module dataflow for event assembly, activity intervalization, vehicle-usage intervalization, evidence attachment, support evidence normalization, and final derived projections."); + notes.add("Selected driver partitions: " + driverResults.size() + "."); + + UnifiedRuntimeDriverWorkingTimeScopeResultDto result = new UnifiedRuntimeDriverWorkingTimeScopeResultDto( + broadBundle.request(), + broadBundle.mergedEvents().size(), + driverResults.size(), + broadBundle.discoveredVehicles().size(), + broadBundle.discoveredVehicles(), + driverResults, + partitionDebug(driverResults), + notes, + List.copyOf(warnings) + ); Map metadata = new LinkedHashMap<>(); metadata.put("inputEventCount", result.inputEventCount()); metadata.put("selectedDriverCount", result.selectedDriverCount()); @@ -59,6 +115,36 @@ public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcess ); } + private RuntimeProcessingModuleResult executeLegacy(RuntimeProcessingModuleContext context) { + UnifiedRuntimeProcessingApiRequest scopeRequest = scopeRequest(context); + boolean includePartitionDebug = booleanAttribute(context, "includePartitionDebug", false); + UnifiedRuntimeDriverWorkingTimeScopeResultDto result = legacyScopeProcessingService.processScope( + scopeRequest, + includePartitionDebug + ); + Map metadata = new LinkedHashMap<>(); + metadata.put("inputEventCount", result.inputEventCount()); + metadata.put("selectedDriverCount", result.selectedDriverCount()); + metadata.put("discoveredVehicleCount", result.discoveredVehicleCount()); + metadata.put("driverResultCount", result.driverResults().size()); + return new RuntimeProcessingModuleResult( + moduleKey(), + RuntimeProcessingModuleStatus.SUCCESS, + result, + metadata, + result.warnings() + ); + } + + private UnifiedRuntimeEventBundle runtimeEventBundle(RuntimeProcessingModuleContext context) { + RuntimeProcessingModuleResult result = context.previousResults().get(DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY); + if (result != null && result.output() instanceof UnifiedRuntimeEventBundle bundle) { + return bundle; + } + throw new IllegalStateException("Module " + moduleKey() + + " requires previous result " + DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY + "."); + } + private UnifiedRuntimeProcessingApiRequest scopeRequest(RuntimeProcessingModuleContext context) { Object value = context.attributes().get("runtimeScopeApiRequest"); if (value instanceof UnifiedRuntimeProcessingApiRequest request) { @@ -67,6 +153,29 @@ public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcess return context.request().sourceSelection(); } + @SuppressWarnings("unchecked") + private Map preparedInputs(RuntimeProcessingModuleContext context) { + RuntimeProcessingModuleResult result = + context.previousResults().get(DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION); + if (result == null || !(result.output() instanceof Map map)) { + return Map.of(); + } + return (Map) map; + } + + private Map partitionDebug( + Map driverResults + ) { + LinkedHashMap debugByDriver = + new LinkedHashMap<>(); + driverResults.forEach((driverKey, result) -> { + if (result.partitionDebug() != null) { + debugByDriver.put(driverKey, result.partitionDebug()); + } + }); + return debugByDriver; + } + private boolean booleanAttribute(RuntimeProcessingModuleContext context, String key, boolean fallback) { Object value = context.attributes().get(key); if (value instanceof Boolean booleanValue) { diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/SupportEvidenceNormalizationModule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/SupportEvidenceNormalizationModule.java index b5df72c..1cc437b 100644 --- a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/SupportEvidenceNormalizationModule.java +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/SupportEvidenceNormalizationModule.java @@ -1,18 +1,268 @@ package at.procon.eventhub.processing.eventprocessing.module; +import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeDriverPartition; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeProcessingInput; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval; +import at.procon.eventhub.processing.dto.RuntimeSupportEvidenceNormalizationDebugDto; +import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingModuleDescriptorDto; +import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceEvent; +import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceNormalizationResult; +import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceNormalizer; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component -public class SupportEvidenceNormalizationModule extends AbstractDriverWorkingTimePhaseModule { +public class SupportEvidenceNormalizationModule implements RuntimeProcessingModule { + private final RuntimeSupportEvidenceNormalizer supportEvidenceNormalizer; + private final EventHubProperties properties; + + @Autowired + public SupportEvidenceNormalizationModule( + RuntimeSupportEvidenceNormalizer supportEvidenceNormalizer, + EventHubProperties properties + ) { + this.supportEvidenceNormalizer = supportEvidenceNormalizer; + this.properties = properties; + } + + /** Compatibility constructor for legacy tests/local registries that still delegate the phase. */ public SupportEvidenceNormalizationModule() { - super( - DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION, + this.supportEvidenceNormalizer = null; + this.properties = null; + } + + @Override + public String moduleKey() { + return DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION; + } + + @Override + public RuntimeProcessingModuleDescriptorDto descriptor() { + return new RuntimeProcessingModuleDescriptorDto( + moduleKey(), "Support evidence normalization", - "Normalizes mixed-source support evidence for driver working-time processing.", + "Normalizes mixed-source support evidence into typed per-driver working-time inputs for the common processing core.", "JAVA", - Set.of("RuntimeSupportEvidenceNormalizationDebugDto") + Set.of("Map") ); } + + @Override + public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) { + if (supportEvidenceNormalizer == null || properties == null) { + return delegatedPlaceholder(); + } + UnifiedRuntimeProcessingApiRequest scopeRequest = scopeRequest(context); + UnifiedRuntimeProcessingRequest request = scopeRequest.toRuntimeRequest(); + Map partitions = partitions(context); + List activityIntervals = activityIntervals(context); + + LinkedHashMap preparedInputs = new LinkedHashMap<>(); + List warnings = new ArrayList<>(); + for (Map.Entry entry : partitions.entrySet()) { + String driverKey = entry.getKey(); + DriverWorkingTimeDriverPartition partition = entry.getValue(); + List driverActivityIntervals = activityIntervals.stream() + .filter(interval -> Objects.equals(driverKey, interval.driverKey())) + .sorted(Comparator.comparing(DriverWorkingTimeActivityInterval::startedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(DriverWorkingTimeActivityInterval::endedAt, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + + RuntimeSupportEvidenceNormalizationResult normalizationResult = + supportEvidenceNormalizer.normalizeForTachographDriver(driverKey, partition.mergedEvents()); + List supportEvidenceEvents = normalizationResult.normalizedEvents().stream() + .map(event -> supportEvidenceNormalizer.toSupportEvidenceEvent(driverKey, event)) + .filter(Objects::nonNull) + .filter(event -> event.occurredAt() != null) + .toList(); + RuntimeSupportEvidenceNormalizationDebugDto normalizationDebug = + new RuntimeSupportEvidenceNormalizationDebugDto( + normalizationResult.inputEventCount(), + normalizationResult.normalizedSupportEvidenceEventCount(), + normalizationResult.unchangedEventCount(), + normalizationResult.notes() + ); + + List notes = new ArrayList<>(partition.notes()); + notes.addAll(normalizationResult.notes()); + notes.add("Support evidence normalization produced " + supportEvidenceEvents.size() + + " typed support evidence event(s) for driver " + driverKey + "."); + List partitionWarnings = new ArrayList<>(partition.warnings()); + warnings.addAll(partitionWarnings); + + DriverWorkingTimeDriverPartition normalizedPartition = partition.withSupportEvidence( + supportEvidenceEvents, + normalizationDebug, + notes, + partitionWarnings + ); + DriverWorkingTimeProcessingInput processingInput = new DriverWorkingTimeProcessingInput( + runtimeSessionId(request), + driverKey, + resolveSourceKind(normalizedPartition, driverActivityIntervals), + resolveLoadedFrom(normalizedPartition, driverActivityIntervals, supportEvidenceEvents), + resolveLoadedTo(normalizedPartition, driverActivityIntervals, supportEvidenceEvents), + scopeRequest.occurredFrom(), + scopeRequest.occurredTo(), + scopeRequest.significantDrivingMinutes() == null + ? properties.getTachographFileSession().getProcessing().getSignificantDrivingMinutes() + : scopeRequest.significantDrivingMinutes(), + scopeRequest.minimumRestPeriodMinutes() == null + ? properties.getTachographFileSession().getProcessing().getMinimumRestPeriodMinutes() + : scopeRequest.minimumRestPeriodMinutes(), + driverActivityIntervals, + normalizedPartition.vehicleUsageIntervals(), + supportEvidenceEvents, + notes + ); + preparedInputs.put(driverKey, new DriverWorkingTimePreparedInput( + driverKey, + normalizedPartition, + processingInput + )); + } + + Map metadata = new LinkedHashMap<>(); + metadata.put("selectedDriverCount", preparedInputs.size()); + metadata.put("typedSupportEvidenceEventCount", preparedInputs.values().stream() + .mapToInt(value -> value.processingInput().supportEvidenceEvents().size()) + .sum()); + return new RuntimeProcessingModuleResult( + moduleKey(), + RuntimeProcessingModuleStatus.SUCCESS, + Map.copyOf(preparedInputs), + metadata, + List.copyOf(warnings) + ); + } + + private RuntimeProcessingModuleResult delegatedPlaceholder() { + Map metadata = new LinkedHashMap<>(); + metadata.put("executionModel", "delegated"); + metadata.put("delegatedTo", DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS); + metadata.put("note", "This logical module is executed inside the legacy derived projections adapter."); + return new RuntimeProcessingModuleResult( + moduleKey(), + RuntimeProcessingModuleStatus.SUCCESS, + Map.of(), + metadata, + List.of() + ); + } + + private UnifiedRuntimeProcessingApiRequest scopeRequest(RuntimeProcessingModuleContext context) { + Object value = context.attributes().get("runtimeScopeApiRequest"); + if (value instanceof UnifiedRuntimeProcessingApiRequest request) { + return request; + } + return context.request().sourceSelection(); + } + + @SuppressWarnings("unchecked") + private Map partitions(RuntimeProcessingModuleContext context) { + RuntimeProcessingModuleResult result = context.previousResults().get(DriverWorkingTimeModuleKeys.VEHICLE_EVIDENCE_ATTACHMENT); + if (result == null || !(result.output() instanceof Map map)) { + return Map.of(); + } + return (Map) map; + } + + @SuppressWarnings("unchecked") + private List activityIntervals(RuntimeProcessingModuleContext context) { + RuntimeProcessingModuleResult result = context.previousResults().get(DriverWorkingTimeModuleKeys.EVENT_TO_ACTIVITY_INTERVALS); + if (result == null || !(result.output() instanceof List list)) { + return List.of(); + } + return (List) list; + } + + private UUID runtimeSessionId(UnifiedRuntimeProcessingRequest request) { + if (request.compositeSessionId() != null || request.sessionIds().size() > 1) { + return null; + } + return request.sessionIds().size() == 1 ? request.sessionIds().get(0) : request.sessionId(); + } + + private String resolveSourceKind( + DriverWorkingTimeDriverPartition partition, + List driverActivityIntervals + ) { + for (DriverWorkingTimeVehicleUsageInterval interval : partition.vehicleUsageIntervals()) { + if (interval.sourceKind() != null && !interval.sourceKind().isBlank()) { + return interval.sourceKind(); + } + } + for (DriverWorkingTimeActivityInterval interval : driverActivityIntervals) { + if (interval.sourceKind() != null && !interval.sourceKind().isBlank()) { + return interval.sourceKind(); + } + } + return "UNIFIED_RUNTIME"; + } + + private OffsetDateTime resolveLoadedFrom( + DriverWorkingTimeDriverPartition partition, + List driverActivityIntervals, + List supportEvidenceEvents + ) { + return earliest( + driverActivityIntervals.stream().map(DriverWorkingTimeActivityInterval::startedAt).toList(), + partition.vehicleUsageIntervals().stream().map(DriverWorkingTimeVehicleUsageInterval::startedAt).toList(), + supportEvidenceEvents.stream().map(RuntimeSupportEvidenceEvent::occurredAt).toList() + ); + } + + private OffsetDateTime resolveLoadedTo( + DriverWorkingTimeDriverPartition partition, + List driverActivityIntervals, + List supportEvidenceEvents + ) { + return latest( + driverActivityIntervals.stream().map(DriverWorkingTimeActivityInterval::endedAt).toList(), + partition.vehicleUsageIntervals().stream().map(DriverWorkingTimeVehicleUsageInterval::endedAt).toList(), + supportEvidenceEvents.stream().map(RuntimeSupportEvidenceEvent::occurredAt).toList() + ); + } + + @SafeVarargs + private final OffsetDateTime earliest(List... values) { + OffsetDateTime earliest = null; + for (List candidates : values) { + for (OffsetDateTime candidate : candidates == null ? List.of() : candidates) { + if (candidate != null && (earliest == null || candidate.isBefore(earliest))) { + earliest = candidate; + } + } + } + return earliest; + } + + @SafeVarargs + private final OffsetDateTime latest(List... values) { + OffsetDateTime latest = null; + for (List candidates : values) { + for (OffsetDateTime candidate : candidates == null ? List.of() : candidates) { + if (candidate != null && (latest == null || candidate.isAfter(latest))) { + latest = candidate; + } + } + } + return latest; + } } diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/VehicleEvidenceAttachmentModule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/VehicleEvidenceAttachmentModule.java index 9300063..8b3ef40 100644 --- a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/VehicleEvidenceAttachmentModule.java +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/VehicleEvidenceAttachmentModule.java @@ -1,18 +1,310 @@ package at.procon.eventhub.processing.eventprocessing.module; +import at.procon.eventhub.dto.DriverRefDto; +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.dto.VehicleRefDto; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeDriverPartition; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval; +import at.procon.eventhub.processing.dto.RuntimeDriverPartitionDebugDto; +import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingModuleDescriptorDto; +import at.procon.eventhub.processing.model.RuntimeDriverVehicleEvidenceAttachmentResult; +import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef; +import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import at.procon.eventhub.processing.service.RuntimeDriverVehicleEvidenceAttachmentService; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component -public class VehicleEvidenceAttachmentModule extends AbstractDriverWorkingTimePhaseModule { +public class VehicleEvidenceAttachmentModule implements RuntimeProcessingModule { + private final RuntimeDriverVehicleEvidenceAttachmentService vehicleEvidenceAttachmentService; + + @Autowired + public VehicleEvidenceAttachmentModule( + RuntimeDriverVehicleEvidenceAttachmentService vehicleEvidenceAttachmentService + ) { + this.vehicleEvidenceAttachmentService = vehicleEvidenceAttachmentService; + } + + /** Compatibility constructor for legacy tests/local registries that still delegate the phase. */ public VehicleEvidenceAttachmentModule() { - super( - DriverWorkingTimeModuleKeys.VEHICLE_EVIDENCE_ATTACHMENT, + this.vehicleEvidenceAttachmentService = null; + } + + @Override + public String moduleKey() { + return DriverWorkingTimeModuleKeys.VEHICLE_EVIDENCE_ATTACHMENT; + } + + @Override + public RuntimeProcessingModuleDescriptorDto descriptor() { + return new RuntimeProcessingModuleDescriptorDto( + moduleKey(), "Vehicle evidence attachment", - "Attaches vehicle-only evidence to driver partitions by overlapping driver vehicle-usage intervals.", + "Partitions the broad runtime scope by driver and attaches vehicle-only evidence using merged vehicle-usage intervals from the common pipeline.", "JAVA", - Set.of("RuntimeDriverPartitionDebugDto") + Set.of("Map") ); } + + @Override + public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) { + if (vehicleEvidenceAttachmentService == null) { + return delegatedPlaceholder(); + } + UnifiedRuntimeEventBundle broadBundle = runtimeEventBundle(context); + UnifiedRuntimeProcessingApiRequest scopeRequest = scopeRequest(context); + boolean includePartitionDebug = booleanAttribute(context, "includePartitionDebug", false); + List mergedVehicleUsageIntervals = mergedVehicleUsageIntervals(context); + + LinkedHashMap partitions = new LinkedHashMap<>(); + LinkedHashMap> attachedVehicleEvidenceByEvent = new LinkedHashMap<>(); + List warnings = new ArrayList<>(); + for (String driverKey : selectedDriverKeys(scopeRequest.toRuntimeRequest(), broadBundle.mergedEvents())) { + List directDriverEvents = broadBundle.mergedEvents().stream() + .filter(event -> Objects.equals(driverKey(event), driverKey)) + .toList(); + List driverVehicleUsageIntervals = mergedVehicleUsageIntervals.stream() + .filter(interval -> Objects.equals(driverKey, interval.driverKey())) + .toList(); + RuntimeDriverVehicleEvidenceAttachmentResult attachmentResult = vehicleEvidenceAttachmentService.attachVehicleEvidence( + driverKey, + directDriverEvents, + broadBundle.mergedEvents(), + driverVehicleUsageIntervals, + scopeRequest.expandVehicleEvents() == null || scopeRequest.expandVehicleEvents(), + scopeRequest.vehicleExpansionPaddingMinutes() == null ? 0 : scopeRequest.vehicleExpansionPaddingMinutes(), + includePartitionDebug + ); + for (EventHubEventDto attachedEvent : attachmentResult.attachedVehicleEvidenceEvents()) { + attachedVehicleEvidenceByEvent + .computeIfAbsent(dedupKey(attachedEvent), ignored -> new ArrayList<>()) + .add(driverKey); + } + RuntimeDriverPartitionDebugDto partitionDebug = includePartitionDebug ? attachmentResult.toPartitionDebug() : null; + partitions.put(driverKey, new DriverWorkingTimeDriverPartition( + driverKey, + attachmentResult.directDriverEvents(), + attachmentResult.attachedVehicleEvidenceEvents(), + attachmentResult.mergedEvents(), + discoverVehicles(attachmentResult.mergedEvents()), + driverVehicleUsageIntervals, + partitionDebug, + List.of(), + null, + attachmentResult.notes(), + attachmentResult.warnings() + )); + warnings.addAll(attachmentResult.warnings()); + } + + attachedVehicleEvidenceByEvent.forEach((eventKey, drivers) -> { + if (drivers.size() > 1) { + warnings.add("Vehicle-only event " + eventKey + " was attached to multiple driver partitions " + + drivers + "; check overlapping vehicle-usage intervals."); + } + }); + + Map metadata = new LinkedHashMap<>(); + metadata.put("selectedDriverCount", partitions.size()); + metadata.put("inputVehicleUsageIntervalCount", mergedVehicleUsageIntervals.size()); + metadata.put("attachedVehicleEvidenceEventCount", partitions.values().stream() + .mapToInt(partition -> partition.attachedVehicleEvidenceEvents().size()) + .sum()); + return new RuntimeProcessingModuleResult( + moduleKey(), + RuntimeProcessingModuleStatus.SUCCESS, + Map.copyOf(partitions), + metadata, + List.copyOf(warnings) + ); + } + + private RuntimeProcessingModuleResult delegatedPlaceholder() { + Map metadata = new LinkedHashMap<>(); + metadata.put("executionModel", "delegated"); + metadata.put("delegatedTo", DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS); + metadata.put("note", "This logical module is executed inside the legacy derived projections adapter."); + return new RuntimeProcessingModuleResult( + moduleKey(), + RuntimeProcessingModuleStatus.SUCCESS, + Map.of(), + metadata, + List.of() + ); + } + + private UnifiedRuntimeEventBundle runtimeEventBundle(RuntimeProcessingModuleContext context) { + RuntimeProcessingModuleResult result = context.previousResults().get(DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY); + if (result != null && result.output() instanceof UnifiedRuntimeEventBundle bundle) { + return bundle; + } + throw new IllegalStateException("Module " + moduleKey() + + " requires previous result " + DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY + "."); + } + + private UnifiedRuntimeProcessingApiRequest scopeRequest(RuntimeProcessingModuleContext context) { + Object value = context.attributes().get("runtimeScopeApiRequest"); + if (value instanceof UnifiedRuntimeProcessingApiRequest request) { + return request; + } + return context.request().sourceSelection(); + } + + @SuppressWarnings("unchecked") + private List mergedVehicleUsageIntervals(RuntimeProcessingModuleContext context) { + RuntimeProcessingModuleResult result = context.previousResults().get(DriverWorkingTimeModuleKeys.VEHICLE_USAGE_MERGE); + if (result == null || !(result.output() instanceof List list)) { + return List.of(); + } + return (List) list; + } + + private LinkedHashSet selectedDriverKeys( + UnifiedRuntimeProcessingRequest request, + List events + ) { + LinkedHashSet allDrivers = discoverDriverKeys(events); + if (request.includeAllDrivers()) { + return allDrivers; + } + LinkedHashSet requested = new LinkedHashSet<>(request.driverKeys()); + if (request.driverKey() != null) { + requested.add(request.driverKey()); + } + if (requested.isEmpty()) { + return allDrivers; + } + LinkedHashSet selected = new LinkedHashSet<>(); + for (String driverKey : allDrivers) { + if (requested.contains(driverKey)) { + selected.add(driverKey); + } + } + selected.addAll(requested); + return selected; + } + + private LinkedHashSet discoverDriverKeys(List events) { + LinkedHashSet result = new LinkedHashSet<>(); + for (EventHubEventDto event : sort(events)) { + String driverKey = driverKey(event); + if (driverKey != null) { + result.add(driverKey); + } + } + return result; + } + + private List discoverVehicles(List events) { + List result = new ArrayList<>(); + for (EventHubEventDto event : events == null ? List.of() : events) { + UnifiedDiscoveredVehicleRef candidate = vehicleRef(event.vehicleRef()); + if (candidate == null || !candidate.hasAnyReference()) { + continue; + } + boolean merged = false; + for (int i = 0; i < result.size(); i++) { + UnifiedDiscoveredVehicleRef existing = result.get(i); + if (existing.matches(candidate)) { + result.set(i, existing.merge(candidate)); + merged = true; + break; + } + } + if (!merged) { + result.add(candidate); + } + } + result.sort(Comparator.comparing(UnifiedDiscoveredVehicleRef::stableKey)); + return List.copyOf(result); + } + + private UnifiedDiscoveredVehicleRef vehicleRef(VehicleRefDto vehicleRef) { + if (vehicleRef == null || !vehicleRef.hasAnyReference()) { + return null; + } + return new UnifiedDiscoveredVehicleRef( + vehicleRef.sourceVehicleEntityId(), + vehicleRef.vin(), + vehicleRef.vehicleRegistration() == null + ? null + : vehicleRef.vehicleRegistration().nationNumericCode() == null + ? vehicleRef.vehicleRegistration().nation() + : vehicleRef.vehicleRegistration().nationNumericCode().toString(), + vehicleRef.vehicleRegistration() == null ? null : vehicleRef.vehicleRegistration().number() + ); + } + + private List sort(List events) { + return (events == null ? List.of() : events).stream() + .sorted(Comparator.comparing(EventHubEventDto::occurredAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(event -> event.eventDomain() == null ? "" : event.eventDomain().name()) + .thenComparing(event -> event.eventType() == null ? "" : event.eventType().name()) + .thenComparing(event -> event.lifecycle() == null ? "" : event.lifecycle().name()) + .thenComparing(EventHubEventDto::externalSourceEventId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private String driverKey(EventHubEventDto event) { + if (event == null) { + return null; + } + String rawDriverKey = text(rawPayload(event), "driverKey"); + if (rawDriverKey != null) { + return rawDriverKey; + } + DriverRefDto driverRef = event.driverRef(); + if (driverRef != null && driverRef.hasAnyReference()) { + return driverRef.stableKey(); + } + return null; + } + + private JsonNode rawPayload(EventHubEventDto event) { + JsonNode payload = event == null ? null : event.payload(); + if (payload == null || payload.isNull()) { + return null; + } + JsonNode raw = payload.get("raw"); + return raw == null || raw.isNull() ? payload : raw; + } + + private String text(JsonNode node, String field) { + if (node == null || field == null) { + return null; + } + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + String text = value.asText(null); + return text == null || text.isBlank() ? null : text.trim(); + } + + private String dedupKey(EventHubEventDto event) { + String sourceKey = event.packageInfo() != null && event.packageInfo().eventSource() != null + ? event.packageInfo().eventSource().stableKey() + : "NO_SOURCE"; + return sourceKey + "|" + event.externalSourceEventId(); + } + + private boolean booleanAttribute(RuntimeProcessingModuleContext context, String key, boolean fallback) { + Object value = context.attributes().get(key); + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + return fallback; + } } diff --git a/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java b/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java index ba62a37..6c525e5 100644 --- a/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java +++ b/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java @@ -2,6 +2,7 @@ package at.procon.eventhub.processing.service; import at.procon.eventhub.dto.EventHubEventDto; import at.procon.eventhub.dto.VehicleRefDto; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval; import at.procon.eventhub.processing.dto.RuntimeVehicleEvidenceAttachmentDecisionDto; import at.procon.eventhub.processing.dto.RuntimeVehicleUsageIntervalDebugDto; import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeClassifier; @@ -58,9 +59,41 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { boolean attachVehicleOnlyEvents, int vehicleEvidencePaddingMinutes, boolean includeDebug + ) { + ResolvedDriverTimeline timeline = timelineReconstructor.reconstruct( + null, + driverKey, + directDriverEvents == null ? List.of() : directDriverEvents + ); + List vehicleUsageIntervals = mergeVehicleUsageIntervals(timeline.vehicleUsageIntervals()) + .stream() + .map(DriverWorkingTimeVehicleUsageInterval::fromResolved) + .filter(Objects::nonNull) + .toList(); + return attachVehicleEvidence( + driverKey, + directDriverEvents, + runtimeScopeEvents, + vehicleUsageIntervals, + attachVehicleOnlyEvents, + vehicleEvidencePaddingMinutes, + includeDebug + ); + } + + public RuntimeDriverVehicleEvidenceAttachmentResult attachVehicleEvidence( + String driverKey, + List directDriverEvents, + List runtimeScopeEvents, + List vehicleUsageIntervals, + boolean attachVehicleOnlyEvents, + int vehicleEvidencePaddingMinutes, + boolean includeDebug ) { List safeDriverEvents = directDriverEvents == null ? List.of() : List.copyOf(directDriverEvents); List safeScopeEvents = runtimeScopeEvents == null ? List.of() : List.copyOf(runtimeScopeEvents); + List safeVehicleUsageIntervals = + mergeDriverWorkingTimeVehicleUsageIntervals(vehicleUsageIntervals); int paddingMinutes = Math.max(0, vehicleEvidencePaddingMinutes); List notes = new ArrayList<>(); @@ -85,10 +118,8 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { ); } - ResolvedDriverTimeline timeline = timelineReconstructor.reconstruct(null, driverKey, safeDriverEvents); - List usageIntervals = mergeVehicleUsageIntervals(timeline.vehicleUsageIntervals()); List usageIntervalDebug = includeDebug - ? usageIntervals.stream().map(RuntimeVehicleUsageIntervalDebugDto::from).toList() + ? safeVehicleUsageIntervals.stream().map(RuntimeVehicleUsageIntervalDebugDto::from).filter(Objects::nonNull).toList() : List.of(); List decisions = includeDebug ? new ArrayList<>(directDriverDecisions(safeDriverEvents)) @@ -99,7 +130,8 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { List attached = new ArrayList<>(); int ignored = 0; for (EventHubEventDto vehicleEvent : candidateVehicleEvidence) { - List matchingIntervals = matchingUsageIntervals(vehicleEvent, usageIntervals, paddingMinutes); + List matchingIntervals = + matchingUsageIntervals(vehicleEvent, safeVehicleUsageIntervals, paddingMinutes); if (matchingIntervals.isEmpty()) { ignored++; if (includeDebug) { @@ -118,7 +150,7 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { "ATTACHED_VEHICLE_EVIDENCE", "Vehicle-scoped event overlapped driver vehicle usage interval(s).", vehicleEvent, - matchingIntervals + intervalIds(matchingIntervals) )); } if (matchingIntervals.size() > 1) { @@ -128,13 +160,13 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { } } - notes.add("Vehicle-only evidence attachment used " + usageIntervals.size() + notes.add("Vehicle-only evidence attachment used " + safeVehicleUsageIntervals.size() + " reconstructed vehicle-usage interval(s) for driver " + driverKey + "."); notes.add("Vehicle-only evidence padding minutes: " + paddingMinutes + "."); notes.add("Candidate vehicle-only evidence events: " + candidateVehicleEvidence.size() + "."); notes.add("Attached vehicle-only evidence events: " + attached.size() + "."); notes.add("Ignored vehicle-only evidence events: " + ignored + "."); - if (usageIntervals.isEmpty() && !candidateVehicleEvidence.isEmpty()) { + if (safeVehicleUsageIntervals.isEmpty() && !candidateVehicleEvidence.isEmpty()) { warnings.add("Vehicle-only evidence was available for driver " + driverKey + ", but no driver vehicle-usage intervals were reconstructed; no vehicle-only evidence was attached."); } @@ -144,7 +176,7 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { safeDriverEvents, attached, deduplicateAndSort(safeDriverEvents, attached), - usageIntervals.size(), + safeVehicleUsageIntervals.size(), candidateVehicleEvidence.size(), ignored, usageIntervalDebug, @@ -181,12 +213,8 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { String decision, String reason, EventHubEventDto event, - List matchingIntervals + List intervalIds ) { - List intervalIds = (matchingIntervals == null ? List.of() : matchingIntervals).stream() - .map(ResolvedVehicleUsageInterval::intervalId) - .filter(Objects::nonNull) - .toList(); return new RuntimeVehicleEvidenceAttachmentDecisionDto( decision, reason, @@ -198,20 +226,20 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { event == null || event.lifecycle() == null ? null : event.lifecycle().name(), scopeClassifier.classify(event), vehicleKeys(event), - intervalIds + intervalIds == null ? List.of() : List.copyOf(intervalIds) ); } - private List matchingUsageIntervals( + private List matchingUsageIntervals( EventHubEventDto vehicleEvent, - List usageIntervals, + List usageIntervals, int paddingMinutes ) { if (vehicleEvent == null || vehicleEvent.occurredAt() == null || usageIntervals == null || usageIntervals.isEmpty()) { return List.of(); } - List result = new ArrayList<>(); - for (ResolvedVehicleUsageInterval interval : usageIntervals) { + List result = new ArrayList<>(); + for (DriverWorkingTimeVehicleUsageInterval interval : usageIntervals) { if (matchesVehicle(vehicleEvent, interval) && timeInside(vehicleEvent.occurredAt(), interval, paddingMinutes)) { result.add(interval); } @@ -219,16 +247,20 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { return List.copyOf(result); } - private boolean timeInside(OffsetDateTime occurredAt, ResolvedVehicleUsageInterval interval, int paddingMinutes) { - if (occurredAt == null || interval == null || interval.from() == null) { + private boolean timeInside( + OffsetDateTime occurredAt, + DriverWorkingTimeVehicleUsageInterval interval, + int paddingMinutes + ) { + if (occurredAt == null || interval == null || interval.startedAt() == null) { return false; } - OffsetDateTime from = interval.from().minusMinutes(paddingMinutes); - OffsetDateTime to = interval.to() == null ? OffsetDateTime.MAX : interval.to().plusMinutes(paddingMinutes); + OffsetDateTime from = interval.startedAt().minusMinutes(paddingMinutes); + OffsetDateTime to = interval.endedAt() == null ? OffsetDateTime.MAX : interval.endedAt().plusMinutes(paddingMinutes); return !occurredAt.isBefore(from) && !occurredAt.isAfter(to); } - private boolean matchesVehicle(EventHubEventDto event, ResolvedVehicleUsageInterval interval) { + private boolean matchesVehicle(EventHubEventDto event, DriverWorkingTimeVehicleUsageInterval interval) { if (event == null || interval == null) { return false; } @@ -262,7 +294,7 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { return Set.copyOf(result); } - private Set vehicleKeys(ResolvedVehicleUsageInterval interval) { + private Set vehicleKeys(DriverWorkingTimeVehicleUsageInterval interval) { LinkedHashSet result = new LinkedHashSet<>(); add(result, interval.vehicleKey()); add(result, interval.registrationKey()); @@ -275,6 +307,13 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { return Set.copyOf(result); } + private List intervalIds(List intervals) { + return (intervals == null ? List.of() : intervals).stream() + .map(DriverWorkingTimeVehicleUsageInterval::intervalId) + .filter(Objects::nonNull) + .toList(); + } + private void add(Set keys, String value) { if (value != null && !value.isBlank()) { keys.add(value.trim()); @@ -343,6 +382,77 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { ); } + private List mergeDriverWorkingTimeVehicleUsageIntervals( + List intervals + ) { + if (intervals == null || intervals.isEmpty()) { + return List.of(); + } + List sorted = intervals.stream() + .filter(Objects::nonNull) + .sorted(Comparator.comparing(DriverWorkingTimeVehicleUsageInterval::startedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(DriverWorkingTimeVehicleUsageInterval::endedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(DriverWorkingTimeVehicleUsageInterval::intervalId, Comparator.nullsLast(String::compareTo))) + .toList(); + List merged = new ArrayList<>(); + for (DriverWorkingTimeVehicleUsageInterval next : sorted) { + if (merged.isEmpty()) { + merged.add(next); + continue; + } + DriverWorkingTimeVehicleUsageInterval current = merged.get(merged.size() - 1); + if (canMerge(current, next)) { + merged.set(merged.size() - 1, merge(current, next)); + } else { + merged.add(next); + } + } + return List.copyOf(merged); + } + + private boolean canMerge( + DriverWorkingTimeVehicleUsageInterval left, + DriverWorkingTimeVehicleUsageInterval right + ) { + if (left == null || right == null || left.endedAt() == null || right.startedAt() == null) { + return false; + } + return Objects.equals(left.driverKey(), right.driverKey()) + && Objects.equals(left.registrationKey(), right.registrationKey()) + && Objects.equals(left.vehicleKey(), right.vehicleKey()) + && !right.startedAt().isAfter(left.endedAt().plusSeconds(1)); + } + + private DriverWorkingTimeVehicleUsageInterval merge( + DriverWorkingTimeVehicleUsageInterval left, + DriverWorkingTimeVehicleUsageInterval right + ) { + LinkedHashSet sourceIntervalIds = new LinkedHashSet<>(left.sourceIntervalIds()); + sourceIntervalIds.addAll(right.sourceIntervalIds()); + OffsetDateTime end = left.endedAt(); + if (right.endedAt() != null && (end == null || right.endedAt().isAfter(end))) { + end = right.endedAt(); + } + return new DriverWorkingTimeVehicleUsageInterval( + left.sessionId(), + left.driverKey(), + left.intervalId(), + left.firstSourceIntervalId(), + right.lastSourceIntervalId() == null ? left.lastSourceIntervalId() : right.lastSourceIntervalId(), + left.startedAt(), + end, + left.startedAtEpochSecond(), + end == null ? null : end.toEpochSecond(), + end == null ? left.durationSeconds() : end.toEpochSecond() - left.startedAtEpochSecond(), + left.odometerBeginKm(), + right.odometerEndKm() == null ? left.odometerEndKm() : right.odometerEndKm(), + left.registrationKey(), + left.vehicleKey(), + left.sourceKind(), + List.copyOf(sourceIntervalIds) + ); + } + private List deduplicateAndSort( List directDriverEvents, List vehicleEvidenceEvents diff --git a/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeDerivedProjectionService.java b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeDerivedProjectionService.java index 3db9ea0..98b7688 100644 --- a/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeDerivedProjectionService.java +++ b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeDerivedProjectionService.java @@ -6,7 +6,11 @@ import at.procon.eventhub.dto.EventHubEventDto; import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto; import at.procon.eventhub.processing.dto.RuntimeSupportEvidenceNormalizationDebugDto; import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeProcessingInput; +import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval; import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceNormalizationResult; +import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceEvent; import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceNormalizer; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle; import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; @@ -26,7 +30,6 @@ import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsen import at.procon.eventhub.tachographfilesession.service.DriverTimelineBuilder; import at.procon.eventhub.tachographfilesession.service.DriverTimelineReusableProjectionBuilder; import at.procon.eventhub.processing.driverworkingtime.service.DriverWorkingTimeProcessingCore; -import at.procon.eventhub.tachographfilesession.service.TachographEsperProcessingInput; import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -63,7 +66,7 @@ public class UnifiedRuntimeDerivedProjectionService { driverTimelineBuilder, reusableProjectionBuilder, properties, - new DriverWorkingTimeProcessingCore(new at.procon.eventhub.tachographfilesession.service.TachographEsperProcessingCore(driverTimelineBuilder, reusableProjectionBuilder, properties)), + new DriverWorkingTimeProcessingCore(driverTimelineBuilder, reusableProjectionBuilder, properties), supportEvidenceNormalizer ); } @@ -141,17 +144,31 @@ public class UnifiedRuntimeDerivedProjectionService { notes.add("Projection results are filtered to the requested runtime window. For intervals crossing the boundary, include enough source-event padding in the request."); } - DriverWorkingTimeProcessingResultDto projection = workingTimeProcessingCore.process(TachographEsperProcessingInput.fromEvents( + DriverWorkingTimeProcessingInput processingInput = new DriverWorkingTimeProcessingInput( runtimeSessionId(request), driverKey, - timeline, - normalizedEvents, + timeline.sourceKind(), + timeline.loadedFrom(), + timeline.loadedTo(), requestedFrom, requestedTo, significantDrivingMinutes, minimumRestPeriodMinutes, + timeline.activityIntervals().stream() + .map(interval -> DriverWorkingTimeActivityInterval.fromResolved(runtimeSessionId(request), driverKey, interval)) + .filter(Objects::nonNull) + .toList(), + timeline.vehicleUsageIntervals().stream() + .map(DriverWorkingTimeVehicleUsageInterval::fromResolved) + .filter(Objects::nonNull) + .toList(), + timeline.supportEvents().stream() + .map(this::toSupportEvidenceEvent) + .filter(Objects::nonNull) + .toList(), notes - )); + ); + DriverWorkingTimeProcessingResultDto projection = workingTimeProcessingCore.process(processingInput); notes = projection.notes(); RuntimeSupportEvidenceNormalizationDebugDto normalizationDebug = new RuntimeSupportEvidenceNormalizationDebugDto( @@ -185,6 +202,36 @@ public class UnifiedRuntimeDerivedProjectionService { return request.sessionIds().size() == 1 ? request.sessionIds().get(0) : request.sessionId(); } + private RuntimeSupportEvidenceEvent toSupportEvidenceEvent(ExtractedSupportEvent supportEvent) { + if (supportEvent == null || supportEvent.occurredAt() == null) { + return null; + } + return new RuntimeSupportEvidenceEvent( + supportEvent.eventId(), + null, + null, + supportEvent.eventDomain(), + supportEvent.eventType(), + supportEvent.eventLifecycle(), + supportEvent.driverKey(), + supportEvent.vehicleKey(), + supportEvent.registrationKey(), + supportEvent.occurredAt(), + supportEvent.occurredAt().toEpochSecond(), + supportEvent.latitude(), + supportEvent.longitude(), + supportEvent.country(), + supportEvent.region(), + supportEvent.countryFrom(), + supportEvent.countryTo(), + supportEvent.operation(), + supportEvent.odometerKm(), + supportEvent.avgSpeedKmh(), + supportEvent.maxSpeedKmh(), + java.util.Map.of("rawRecordPath", supportEvent.rawRecordPath()) + ); + } + private String resolveDriverKey( UnifiedRuntimeProcessingRequest request, List events diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCore.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCore.java index 5a3a73f..b2d7b96 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCore.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographEsperProcessingCore.java @@ -2,765 +2,39 @@ package at.procon.eventhub.tachographfilesession.service; import at.procon.eventhub.config.EventHubProperties; import at.procon.eventhub.processing.driverworkingtime.dto.DriverWorkingTimeProcessingResultDto; +import at.procon.eventhub.processing.driverworkingtime.service.DriverWorkingTimeProcessingCore; import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; -import at.procon.eventhub.tachographfilesession.model.*; -import java.time.Duration; -import java.time.OffsetDateTime; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +/** + * @deprecated Use {@link DriverWorkingTimeProcessingCore}. This class remains as a + * compatibility adapter for existing tachograph file-session callers. + */ @Service @Deprecated(forRemoval = false) public class TachographEsperProcessingCore { - private final DriverTimelineBuilder driverTimelineBuilder; - private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder; - private final EventHubProperties properties; + private final DriverWorkingTimeProcessingCore delegate; + + @Autowired + public TachographEsperProcessingCore(DriverWorkingTimeProcessingCore delegate) { + this.delegate = delegate; + } public TachographEsperProcessingCore( DriverTimelineBuilder driverTimelineBuilder, DriverTimelineReusableProjectionBuilder reusableProjectionBuilder, EventHubProperties properties ) { - this.driverTimelineBuilder = driverTimelineBuilder; - this.reusableProjectionBuilder = reusableProjectionBuilder; - this.properties = properties; + this(new DriverWorkingTimeProcessingCore(driverTimelineBuilder, reusableProjectionBuilder, properties)); } public TachographEsperDriverProcessingResultDto process(TachographEsperProcessingInput input) { - return TachographEsperDriverProcessingResultDto.fromDriverWorkingTime(processDriverWorkingTime(input)); + return TachographEsperDriverProcessingResultDto.fromDriverWorkingTime(delegate.process(input)); } public DriverWorkingTimeProcessingResultDto processDriverWorkingTime(TachographEsperProcessingInput input) { - Objects.requireNonNull(input, "input must not be null"); - ResolvedDriverTimeline timeline = Objects.requireNonNull(input.timeline(), "timeline must not be null"); - String driverKey = input.driverKey(); - OffsetDateTime requestedFrom = input.requestedFrom() == null ? timeline.loadedFrom() : utc(input.requestedFrom()); - OffsetDateTime requestedTo = input.requestedTo() == null ? timeline.loadedTo() : utc(input.requestedTo()); - if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) { - throw new IllegalArgumentException("occurredTo must not be before occurredFrom."); - } - int significantDrivingMinutes = Math.max(1, input.significantDrivingMinutes()); - int minimumRestPeriodMinutes = Math.max(1, input.minimumRestPeriodMinutes()); - - List activityIntervals = clipEsperActivityIntervalEvents( - driverTimelineBuilder.buildEsperActivityIntervalEvents(input.sessionId(), driverKey, timeline), - requestedFrom, - requestedTo - ); - List drivingIntervals = clipEsperActivityIntervalEvents( - driverTimelineBuilder.buildEsperDrivingIntervalEvents(input.sessionId(), driverKey, timeline), - requestedFrom, - requestedTo - ); - - TachographEsperDrivingDerivedProjectionBundle derivedProjectionBundle = buildDerivedProjection( - input, - timeline, - significantDrivingMinutes, - minimumRestPeriodMinutes - ); - - List rawDrivingInterruptionIntervals = - derivedProjectionBundle.drivingInterruptionIntervals(); - List drivingInterruptionIntervals = - clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionIntervals, requestedFrom, requestedTo); - List rawDailyWeeklyRestCandidateIntervals = - derivedProjectionBundle.dailyWeeklyRestCandidateIntervals(); - List dailyWeeklyRestCandidateIntervals = - clipEsperDrivingInterruptionIntervalEvents(rawDailyWeeklyRestCandidateIntervals, requestedFrom, requestedTo); - List rawDrivingInterruptionVehicleChangeIntervals = - derivedProjectionBundle.drivingInterruptionVehicleChangeIntervals(); - List drivingInterruptionVehicleChangeIntervals = - clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionVehicleChangeIntervals, requestedFrom, requestedTo); - - List rawVehicleUsageIntervals = - driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline); - List rawVuCardAbsentIntervals = - derivedProjectionBundle.vuCardAbsentIntervals(); - - List potentialHomeOvernightStayIntervals = - clipEsperPotentialHomeOvernightStayIntervalEvents( - derivedProjectionBundle.potentialHomeOvernightStayIntervals(), - rawVuCardAbsentIntervals, - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List dailyWeeklyRestCandidateCoverageIntervals = - clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( - derivedProjectionBundle.dailyWeeklyRestCandidateCoverageIntervals(), - rawVuCardAbsentIntervals, - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List unclassifiedDailyWeeklyRestCandidateCoverageIntervals = - clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( - derivedProjectionBundle.unclassifiedDailyWeeklyRestCandidateCoverageIntervals(), - rawVuCardAbsentIntervals, - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List potentialInVehicleOvernightStayIntervals = - clipEsperPotentialInVehicleOvernightStayIntervalEvents( - derivedProjectionBundle.potentialInVehicleOvernightStayIntervals(), - rawVuCardAbsentIntervals, - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List potentialInVehicleTripIntervals = - clipEsperPotentialInVehicleTripIntervalEvents( - derivedProjectionBundle.potentialInVehicleTripIntervals(), - potentialInVehicleOvernightStayIntervals, - requestedFrom, - requestedTo - ); - List vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents( - rawVehicleUsageIntervals, - requestedFrom, - requestedTo - ); - List vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents( - rawVuCardAbsentIntervals, - requestedFrom, - requestedTo - ); - List supportGeoEvents = clipEsperSupportGeoEvents( - timeline.supportEvents(), - driverKey, - requestedFrom, - requestedTo - ); - - return new DriverWorkingTimeProcessingResultDto( - input.sessionId(), - driverKey, - timeline.sourceKind(), - timeline.loadedFrom(), - timeline.loadedTo(), - requestedFrom, - requestedTo, - activityIntervals.size(), - drivingIntervals.size(), - drivingInterruptionIntervals.size(), - drivingInterruptionVehicleChangeIntervals.size(), - dailyWeeklyRestCandidateIntervals.size(), - dailyWeeklyRestCandidateCoverageIntervals.size(), - unclassifiedDailyWeeklyRestCandidateCoverageIntervals.size(), - potentialHomeOvernightStayIntervals.size(), - potentialInVehicleOvernightStayIntervals.size(), - potentialInVehicleTripIntervals.size(), - vehicleUsageIntervals.size(), - vuCardAbsentIntervals.size(), - supportGeoEvents.size(), - activityIntervals, - drivingIntervals, - drivingInterruptionIntervals, - drivingInterruptionVehicleChangeIntervals, - dailyWeeklyRestCandidateIntervals, - dailyWeeklyRestCandidateCoverageIntervals, - unclassifiedDailyWeeklyRestCandidateCoverageIntervals, - potentialHomeOvernightStayIntervals, - potentialInVehicleOvernightStayIntervals, - potentialInVehicleTripIntervals, - vehicleUsageIntervals, - vuCardAbsentIntervals, - supportGeoEvents, - combinedNotes(input.notes()) - ); - } - - private TachographEsperDrivingDerivedProjectionBundle buildDerivedProjection( - TachographEsperProcessingInput input, - ResolvedDriverTimeline timeline, - int significantDrivingMinutes, - int minimumRestPeriodMinutes - ) { - if (input.forceEventInput() || input.hasEventInputEvents()) { - return reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundleFromEvents( - input.sessionId(), - input.driverKey(), - input.eventInputEvents(), - significantDrivingMinutes, - minimumRestPeriodMinutes - ); - } - if (properties.getTachographFileSession().getProcessing().getDrivingDerivedProjectionInputMode() - == EventHubProperties.DrivingDerivedProjectionInputMode.EVENTS - && input.session() != null - && input.driverSession() != null) { - return reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle( - input.session(), - input.driverSession(), - significantDrivingMinutes, - minimumRestPeriodMinutes - ); - } - return reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle( - input.sessionId(), - input.driverKey(), - timeline, - significantDrivingMinutes, - minimumRestPeriodMinutes - ); - } - - private List combinedNotes(List extraNotes) { - List notes = new ArrayList<>(); - notes.addAll(esperProjectionNotes()); - if (extraNotes != null) { - notes.addAll(extraNotes); - } - return List.copyOf(notes); - } - - - private List clipEsperActivityIntervalEvents( - List intervals, - OffsetDateTime requestedFrom, - OffsetDateTime requestedTo - ) { - if (requestedFrom == null || requestedTo == null) { - return List.of(); - } - return intervals.stream() - .map(interval -> { - OffsetDateTime start = max(interval.startedAt(), requestedFrom); - OffsetDateTime end = min(interval.endedAt(), requestedTo); - if (!end.isAfter(start)) { - return null; - } - boolean clipped = interval.clippedToRequestedPeriod() - || !start.equals(interval.startedAt()) - || !end.equals(interval.endedAt()); - return new TachographEsperActivityIntervalEvent( - interval.sessionId(), - interval.driverKey(), - interval.intervalId(), - interval.activityType(), - interval.cardSlot(), - interval.cardStatus(), - interval.drivingStatus(), - interval.registrationKey(), - interval.vehicleKey(), - interval.sourceKind(), - start, - end, - Duration.between(start, end).getSeconds(), - interval.sourceIntervalIds(), - interval.synthetic(), - clipped, - interval.level() - ); - }) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(TachographEsperActivityIntervalEvent::startedAt) - .thenComparing(TachographEsperActivityIntervalEvent::endedAt) - .thenComparing(TachographEsperActivityIntervalEvent::activityType, Comparator.nullsLast(String::compareTo))) - .toList(); - } - - private List clipEsperVehicleUsageIntervalEvents( - List intervals, - OffsetDateTime requestedFrom, - OffsetDateTime requestedTo - ) { - if (requestedFrom == null || requestedTo == null) { - return List.of(); - } - return intervals.stream() - .map(interval -> { - OffsetDateTime start = max(interval.startedAt(), requestedFrom); - OffsetDateTime end = min(interval.endedAt(), requestedTo); - if (!end.isAfter(start)) { - return null; - } - boolean startClipped = !start.equals(interval.startedAt()); - boolean endClipped = !end.equals(interval.endedAt()); - return new TachographEsperVehicleUsageIntervalEvent( - interval.sessionId(), - interval.driverKey(), - interval.intervalId(), - start, - end, - Duration.between(start, end).getSeconds(), - startClipped ? null : interval.odometerBeginKm(), - endClipped ? null : interval.odometerEndKm(), - interval.registrationKey(), - interval.vehicleKey(), - interval.sourceKind(), - interval.sourceIntervalIds() - ); - }) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt) - .thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt) - .thenComparing(TachographEsperVehicleUsageIntervalEvent::intervalId, Comparator.nullsLast(String::compareTo))) - .toList(); - } - - private List clipEsperSupportGeoEvents( - List supportEvents, - String driverKey, - OffsetDateTime requestedFrom, - OffsetDateTime requestedTo - ) { - if (supportEvents == null || supportEvents.isEmpty() || requestedFrom == null || requestedTo == null) { - return List.of(); - } - return supportEvents.stream() - .filter(event -> event.driverKey() == null || Objects.equals(driverKey, event.driverKey())) - .filter(event -> event.occurredAt() != null) - .filter(event -> event.latitude() != null && event.longitude() != null) - .filter(event -> !event.occurredAt().isBefore(requestedFrom) && !event.occurredAt().isAfter(requestedTo)) - .map(event -> new TachographEsperSupportGeoEvent( - event.eventId(), - event.driverKey(), - event.occurredAt(), - event.eventDomain(), - event.eventType(), - event.eventLifecycle(), - event.registrationKey(), - event.vehicleKey(), - event.country(), - event.region(), - event.countryFrom(), - event.countryTo(), - event.operation(), - event.latitude(), - event.longitude(), - event.odometerKm(), - event.rawRecordPath() - )) - .sorted(Comparator.comparing(TachographEsperSupportGeoEvent::occurredAt) - .thenComparing(TachographEsperSupportGeoEvent::eventDomain, Comparator.nullsLast(String::compareTo)) - .thenComparing(TachographEsperSupportGeoEvent::eventId, Comparator.nullsLast(String::compareTo))) - .toList(); - } - - private List clipEsperDrivingInterruptionIntervalEvents( - List intervals, - OffsetDateTime requestedFrom, - OffsetDateTime requestedTo - ) { - if (requestedFrom == null || requestedTo == null) { - return List.of(); - } - return intervals.stream() - .map(interval -> { - OffsetDateTime start = max(interval.startedAt(), requestedFrom); - OffsetDateTime end = min(interval.endedAt(), requestedTo); - if (!end.isAfter(start)) { - return null; - } - return new TachographEsperDrivingInterruptionIntervalEvent( - interval.sessionId(), - interval.driverKey(), - start, - end, - Duration.between(start, end).getSeconds(), - interval.previousDrivingSourceIntervalId(), - interval.nextDrivingSourceIntervalId(), - interval.previousRegistrationKey(), - interval.nextRegistrationKey(), - interval.previousVehicleKey(), - interval.nextVehicleKey() - ); - }) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(TachographEsperDrivingInterruptionIntervalEvent::startedAt) - .thenComparing(TachographEsperDrivingInterruptionIntervalEvent::endedAt)) - .toList(); - } - - private List clipEsperVuCardAbsentIntervalEvents( - List intervals, - OffsetDateTime requestedFrom, - OffsetDateTime requestedTo - ) { - if (requestedFrom == null || requestedTo == null) { - return List.of(); - } - return intervals.stream() - .map(interval -> { - OffsetDateTime start = max(interval.startedAt(), requestedFrom); - OffsetDateTime end = min(interval.endedAt(), requestedTo); - if (!end.isAfter(start)) { - return null; - } - return new TachographEsperVuCardAbsentIntervalEvent( - interval.sessionId(), - interval.driverKey(), - start, - end, - Duration.between(start, end).getSeconds(), - interval.previousUsageIntervalId(), - interval.nextUsageIntervalId(), - interval.previousRegistrationKey(), - interval.nextRegistrationKey(), - interval.previousVehicleKey(), - interval.nextVehicleKey() - ); - }) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(TachographEsperVuCardAbsentIntervalEvent::startedAt) - .thenComparing(TachographEsperVuCardAbsentIntervalEvent::endedAt)) - .toList(); - } - - private List clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents( - List intervals, - List rawVuCardAbsentIntervals, - List rawVehicleUsageIntervals, - OffsetDateTime requestedFrom, - OffsetDateTime requestedTo - ) { - if (requestedFrom == null || requestedTo == null) { - return List.of(); - } - return intervals.stream() - .map(interval -> { - OffsetDateTime start = max(interval.startedAt(), requestedFrom); - OffsetDateTime end = min(interval.endedAt(), requestedTo); - if (!end.isAfter(start)) { - return null; - } - long durationSeconds = Duration.between(start, end).getSeconds(); - boolean beginBoundaryChanged = !start.equals(interval.startedAt()); - boolean endBoundaryChanged = !end.equals(interval.endedAt()); - return new TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent( - interval.sessionId(), - interval.driverKey(), - start, - end, - durationSeconds, - interval.cardAbsentDurationSeconds(), - interval.cardAbsentCoveragePercent(), - interval.previousDrivingSourceIntervalId(), - interval.nextDrivingSourceIntervalId(), - interval.previousRegistrationKey(), - interval.nextRegistrationKey(), - interval.previousVehicleKey(), - interval.nextVehicleKey(), - beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(), - endBoundaryChanged ? null : interval.endBoundaryOdometerKm(), - beginBoundaryChanged ? null : interval.beginGeoEventId(), - beginBoundaryChanged ? null : interval.beginGeoEventDomain(), - beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), - beginBoundaryChanged ? null : interval.beginLatitude(), - beginBoundaryChanged ? null : interval.beginLongitude(), - beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(), - beginBoundaryChanged ? null : interval.beginGeoOdometerKm(), - endBoundaryChanged ? null : interval.endGeoEventId(), - endBoundaryChanged ? null : interval.endGeoEventDomain(), - endBoundaryChanged ? null : interval.endGeoOccurredAt(), - endBoundaryChanged ? null : interval.endLatitude(), - endBoundaryChanged ? null : interval.endLongitude(), - endBoundaryChanged ? null : interval.endGeoDistanceSeconds(), - endBoundaryChanged ? null : interval.endGeoOdometerKm(), - beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(), - beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(), - beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()), - endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm()) - ); - }) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::startedAt) - .thenComparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::endedAt)) - .toList(); - } - - private List clipEsperPotentialHomeOvernightStayIntervalEvents( - List intervals, - List rawVuCardAbsentIntervals, - List rawVehicleUsageIntervals, - OffsetDateTime requestedFrom, - OffsetDateTime requestedTo - ) { - if (requestedFrom == null || requestedTo == null) { - return List.of(); - } - return intervals.stream() - .map(interval -> { - OffsetDateTime start = max(interval.startedAt(), requestedFrom); - OffsetDateTime end = min(interval.endedAt(), requestedTo); - if (!end.isAfter(start)) { - return null; - } - long durationSeconds = Duration.between(start, end).getSeconds(); - boolean beginBoundaryChanged = !start.equals(interval.startedAt()); - boolean endBoundaryChanged = !end.equals(interval.endedAt()); - return new TachographEsperPotentialHomeOvernightStayIntervalEvent( - interval.sessionId(), - interval.driverKey(), - start, - end, - durationSeconds, - interval.cardAbsentDurationSeconds(), - interval.cardAbsentCoveragePercent(), - interval.previousDrivingSourceIntervalId(), - interval.nextDrivingSourceIntervalId(), - interval.previousRegistrationKey(), - interval.nextRegistrationKey(), - interval.previousVehicleKey(), - interval.nextVehicleKey(), - beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(), - endBoundaryChanged ? null : interval.endBoundaryOdometerKm(), - beginBoundaryChanged ? null : interval.beginGeoEventId(), - beginBoundaryChanged ? null : interval.beginGeoEventDomain(), - beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), - beginBoundaryChanged ? null : interval.beginLatitude(), - beginBoundaryChanged ? null : interval.beginLongitude(), - beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(), - beginBoundaryChanged ? null : interval.beginGeoOdometerKm(), - endBoundaryChanged ? null : interval.endGeoEventId(), - endBoundaryChanged ? null : interval.endGeoEventDomain(), - endBoundaryChanged ? null : interval.endGeoOccurredAt(), - endBoundaryChanged ? null : interval.endLatitude(), - endBoundaryChanged ? null : interval.endLongitude(), - endBoundaryChanged ? null : interval.endGeoDistanceSeconds(), - endBoundaryChanged ? null : interval.endGeoOdometerKm(), - beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(), - beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(), - beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()), - endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm()) - ); - }) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::startedAt) - .thenComparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::endedAt)) - .toList(); - } - - private List clipEsperPotentialInVehicleOvernightStayIntervalEvents( - List intervals, - List rawVuCardAbsentIntervals, - List rawVehicleUsageIntervals, - OffsetDateTime requestedFrom, - OffsetDateTime requestedTo - ) { - if (requestedFrom == null || requestedTo == null) { - return List.of(); - } - return intervals.stream() - .map(interval -> { - OffsetDateTime start = max(interval.startedAt(), requestedFrom); - OffsetDateTime end = min(interval.endedAt(), requestedTo); - if (!end.isAfter(start)) { - return null; - } - long durationSeconds = Duration.between(start, end).getSeconds(); - boolean beginBoundaryChanged = !start.equals(interval.startedAt()); - boolean endBoundaryChanged = !end.equals(interval.endedAt()); - return new TachographEsperPotentialInVehicleOvernightStayIntervalEvent( - interval.sessionId(), - interval.driverKey(), - start, - end, - durationSeconds, - interval.cardAbsentDurationSeconds(), - interval.cardAbsentCoveragePercent(), - interval.previousDrivingSourceIntervalId(), - interval.nextDrivingSourceIntervalId(), - interval.previousRegistrationKey(), - interval.nextRegistrationKey(), - interval.previousVehicleKey(), - interval.nextVehicleKey(), - beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(), - endBoundaryChanged ? null : interval.endBoundaryOdometerKm(), - beginBoundaryChanged ? null : interval.beginGeoEventId(), - beginBoundaryChanged ? null : interval.beginGeoEventDomain(), - beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), - beginBoundaryChanged ? null : interval.beginLatitude(), - beginBoundaryChanged ? null : interval.beginLongitude(), - beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(), - beginBoundaryChanged ? null : interval.beginGeoOdometerKm(), - endBoundaryChanged ? null : interval.endGeoEventId(), - endBoundaryChanged ? null : interval.endGeoEventDomain(), - endBoundaryChanged ? null : interval.endGeoOccurredAt(), - endBoundaryChanged ? null : interval.endLatitude(), - endBoundaryChanged ? null : interval.endLongitude(), - endBoundaryChanged ? null : interval.endGeoDistanceSeconds(), - endBoundaryChanged ? null : interval.endGeoOdometerKm(), - beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(), - beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(), - beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()), - endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm()) - ); - }) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt) - .thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt)) - .toList(); - } - - private List clipEsperPotentialInVehicleTripIntervalEvents( - List intervals, - List potentialInVehicleOvernightStayIntervals, - OffsetDateTime requestedFrom, - OffsetDateTime requestedTo - ) { - if (requestedFrom == null || requestedTo == null) { - return List.of(); - } - if (intervals == null || intervals.isEmpty() - || potentialInVehicleOvernightStayIntervals == null || potentialInVehicleOvernightStayIntervals.isEmpty()) { - return List.of(); - } - return intervals.stream() - .map(interval -> { - OffsetDateTime start = max(interval.startedAt(), requestedFrom); - OffsetDateTime end = min(interval.endedAt(), requestedTo); - if (!end.isAfter(start)) { - return null; - } - List containedIntervals = - potentialInVehicleOvernightStayIntervals.stream() - .filter(candidate -> tripContainsPotentialInterval( - interval.driverKey(), - interval.registrationKey(), - interval.vehicleKey(), - start, - end, - candidate - )) - .sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt) - .thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt)) - .toList(); - if (containedIntervals.isEmpty()) { - return null; - } - TachographEsperPotentialInVehicleOvernightStayIntervalEvent first = containedIntervals.get(0); - TachographEsperPotentialInVehicleOvernightStayIntervalEvent last = - containedIntervals.get(containedIntervals.size() - 1); - return new TachographEsperPotentialInVehicleTripIntervalEvent( - interval.sessionId(), - interval.driverKey(), - start, - end, - Duration.between(start, end).getSeconds(), - interval.registrationKey(), - interval.vehicleKey(), - containedIntervals.size(), - containedIntervals.stream() - .mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::durationSeconds) - .sum(), - containedIntervals.stream() - .mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::cardAbsentDurationSeconds) - .sum(), - first.startedAt(), - last.endedAt(), - first.previousDrivingSourceIntervalId(), - last.nextDrivingSourceIntervalId(), - containedIntervals - ); - }) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(TachographEsperPotentialInVehicleTripIntervalEvent::startedAt) - .thenComparing(TachographEsperPotentialInVehicleTripIntervalEvent::endedAt)) - .toList(); - } - - private boolean tripContainsPotentialInterval( - String driverKey, - String registrationKey, - String vehicleKey, - OffsetDateTime tripStartedAt, - OffsetDateTime tripEndedAt, - TachographEsperPotentialInVehicleOvernightStayIntervalEvent candidate - ) { - if (!Objects.equals(driverKey, candidate.driverKey())) { - return false; - } - if (!Objects.equals(registrationKey, candidate.previousRegistrationKey())) { - return false; - } - if (vehicleKey != null && candidate.previousVehicleKey() != null - && !Objects.equals(vehicleKey, candidate.previousVehicleKey())) { - return false; - } - return !candidate.startedAt().isBefore(tripStartedAt) - && !candidate.endedAt().isAfter(tripEndedAt); - } - - - private List esperProjectionNotes() { - return List.of( - "This endpoint returns Esper-backed per-driver interval projections from the in-memory tachograph file-session model.", - "Driving intervals are a filtered projection of activity intervals where activityType = DRIVE.", - "Driving interruption intervals are gaps between consecutive driving intervals longer than the configured significant-driving threshold.", - "Driving interruption vehicle-change intervals are daily/weekly rest candidates where previousRegistrationKey differs from nextRegistrationKey.", - "Daily/weekly rest candidate intervals are driving interruption intervals longer than the configured minimum rest-period threshold.", - "Daily/weekly rest candidate coverage intervals enrich each rest candidate with card-present and card-absent coverage metrics computed from vehicle-usage and VU card-absent overlap.", - "Daily/weekly rest candidate coverage intervals also attach begin/end geo evidence from nearby support events for the same driver and boundary-side vehicle identity.", - "Boundary geo evidence prefers the nearest matching POSITION event, then PLACE, BORDER_CROSSING, and LOAD_UNLOAD within the configured lookback/lookahead windows.", - "If both begin and end geo evidence carry odometer values, geoEvidenceMovementCategory classifies the interval as STATIONARY, MINOR, MOVED, or UNKNOWN.", - "Unclassified daily/weekly rest candidate coverage intervals are the rest candidates that are neither potential home overnight stays nor potential in-vehicle overnight stays.", - "Potential home overnight stay intervals are vehicle-change daily/weekly rest candidate coverage intervals where VU card-absent overlap covers at least 95% of the candidate interval.", - "Potential in-vehicle overnight stay intervals are no-change daily/weekly rest candidate coverage intervals where card-present overlap covers the candidate rest period.", - "Potential in-vehicle trip intervals span from the end of the coverage interval before a same-vehicle in-vehicle-overnight sequence to the start of the first coverage interval after that sequence.", - "VU card-absent intervals are gaps between consecutive normalized vehicle-usage intervals for the same driver.", - "occurredFrom and occurredTo clip the returned interval projections to the requested UTC time window.", - "Vehicle-usage intervals clear clipped odometer endpoints because boundary odometer values cannot be recomputed safely from the source interval." - ); - } - - private OffsetDateTime utc(OffsetDateTime value) { - return value == null ? null : value.withOffsetSameInstant(java.time.ZoneOffset.UTC); - } - - private TachographEsperGeoEvidenceEvent geoEvidenceEvent( - String eventId, - String eventDomain, - OffsetDateTime occurredAt, - Double latitude, - Double longitude, - Long distanceSeconds, - Long odometerKm - ) { - if (eventId == null - && eventDomain == null - && occurredAt == null - && latitude == null - && longitude == null - && distanceSeconds == null - && odometerKm == null) { - return null; - } - return new TachographEsperGeoEvidenceEvent( - eventId, - eventDomain, - occurredAt, - latitude, - longitude, - distanceSeconds, - odometerKm - ); - } - - private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) { - if (left == null) { - return right; - } - if (right == null) { - return left; - } - return left.isAfter(right) ? left : right; - } - - private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) { - if (left == null) { - return right; - } - if (right == null) { - return left; - } - return left.isBefore(right) ? left : right; + return delegate.process(input); } }