Simplify rest card absence metrics

This commit is contained in:
trifonovt 2026-05-22 15:40:35 +02:00
parent f1f36e2204
commit 1128bd3f56
27 changed files with 1229 additions and 280 deletions

View File

@ -0,0 +1,63 @@
package at.procon.eventhub.tachographfilesession.api;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographCompositeSessionRequest;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographCompositeSessionResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeDriverEventsResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeSessionListDriversResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionService;
import jakarta.validation.Valid;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/eventhub/tachograph-composite-sessions")
public class TachographCompositeSessionController {
private final TachographCompositeSessionService service;
public TachographCompositeSessionController(TachographCompositeSessionService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<CreateTachographCompositeSessionResponse> createCompositeSession(
@Valid @RequestBody CreateTachographCompositeSessionRequest request
) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.createCompositeSession(request));
}
@GetMapping("/{compositeSessionId}")
public ResponseEntity<TachographCompositeSessionSummaryDto> getCompositeSession(@PathVariable UUID compositeSessionId) {
return ResponseEntity.ok(service.getCompositeSession(compositeSessionId));
}
@GetMapping("/{compositeSessionId}/drivers")
public ResponseEntity<TachographCompositeSessionListDriversResponse> listDrivers(@PathVariable UUID compositeSessionId) {
return ResponseEntity.ok(service.listDrivers(compositeSessionId));
}
@GetMapping("/{compositeSessionId}/drivers/{driverKey}/events")
public ResponseEntity<TachographCompositeDriverEventsResponse> getMergedDriverEvents(
@PathVariable UUID compositeSessionId,
@PathVariable String driverKey
) {
return ResponseEntity.ok(service.getMergedDriverEvents(compositeSessionId, driverKey));
}
@GetMapping("/{compositeSessionId}/drivers/{driverKey}/timeline")
public ResponseEntity<ResolvedDriverTimeline> getMergedDriverTimeline(
@PathVariable UUID compositeSessionId,
@PathVariable String driverKey
) {
return ResponseEntity.ok(service.getMergedDriverTimeline(compositeSessionId, driverKey));
}
}

View File

@ -0,0 +1,11 @@
package at.procon.eventhub.tachographfilesession.dto;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
import java.util.UUID;
public record CreateTachographCompositeSessionRequest(
@NotEmpty List<UUID> sessionIds,
String label
) {
}

View File

@ -0,0 +1,6 @@
package at.procon.eventhub.tachographfilesession.dto;
public record CreateTachographCompositeSessionResponse(
TachographCompositeSessionSummaryDto session
) {
}

View File

@ -0,0 +1,14 @@
package at.procon.eventhub.tachographfilesession.dto;
import at.procon.eventhub.dto.EventHubEventDto;
import java.util.List;
import java.util.UUID;
public record TachographCompositeDriverEventsResponse(
UUID compositeSessionId,
String driverKey,
List<UUID> sourceSessionIds,
int eventCount,
List<EventHubEventDto> events
) {
}

View File

@ -0,0 +1,10 @@
package at.procon.eventhub.tachographfilesession.dto;
import java.util.List;
import java.util.UUID;
public record TachographCompositeSessionListDriversResponse(
UUID compositeSessionId,
List<TachographFileDriverSummaryDto> drivers
) {
}

View File

@ -0,0 +1,15 @@
package at.procon.eventhub.tachographfilesession.dto;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record TachographCompositeSessionSummaryDto(
UUID compositeSessionId,
String tenantKey,
String label,
List<UUID> memberSessionIds,
List<TachographFileDriverSummaryDto> drivers,
Instant createdAt
) {
}

View File

@ -0,0 +1,17 @@
package at.procon.eventhub.tachographfilesession.model;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record TachographCompositeSession(
UUID compositeSessionId,
String tenantKey,
String label,
List<UUID> memberSessionIds,
Instant createdAt
) {
public TachographCompositeSession {
memberSessionIds = memberSessionIds == null ? List.of() : List.copyOf(memberSessionIds);
}
}

View File

@ -9,10 +9,8 @@ public record TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent(
OffsetDateTime startedAt, OffsetDateTime startedAt,
OffsetDateTime endedAt, OffsetDateTime endedAt,
long durationSeconds, long durationSeconds,
long cardPresentDurationSeconds, long cardAbsentDurationSeconds,
double cardPresentCoveragePercent, double cardAbsentCoveragePercent,
long unknownDurationSeconds,
double unknownCoveragePercent,
String previousDrivingSourceIntervalId, String previousDrivingSourceIntervalId,
String nextDrivingSourceIntervalId, String nextDrivingSourceIntervalId,
String previousRegistrationKey, String previousRegistrationKey,

View File

@ -9,10 +9,8 @@ public record TachographEsperPotentialHomeOvernightStayIntervalEvent(
OffsetDateTime startedAt, OffsetDateTime startedAt,
OffsetDateTime endedAt, OffsetDateTime endedAt,
long durationSeconds, long durationSeconds,
long cardPresentDurationSeconds, long cardAbsentDurationSeconds,
double cardPresentCoveragePercent, double cardAbsentCoveragePercent,
long unknownDurationSeconds,
double unknownCoveragePercent,
String previousDrivingSourceIntervalId, String previousDrivingSourceIntervalId,
String nextDrivingSourceIntervalId, String nextDrivingSourceIntervalId,
String previousRegistrationKey, String previousRegistrationKey,

View File

@ -9,10 +9,8 @@ public record TachographEsperPotentialInVehicleOvernightStayIntervalEvent(
OffsetDateTime startedAt, OffsetDateTime startedAt,
OffsetDateTime endedAt, OffsetDateTime endedAt,
long durationSeconds, long durationSeconds,
long cardPresentDurationSeconds, long cardAbsentDurationSeconds,
double cardPresentCoveragePercent, double cardAbsentCoveragePercent,
long unknownDurationSeconds,
double unknownCoveragePercent,
String previousDrivingSourceIntervalId, String previousDrivingSourceIntervalId,
String nextDrivingSourceIntervalId, String nextDrivingSourceIntervalId,
String previousRegistrationKey, String previousRegistrationKey,

View File

@ -14,8 +14,7 @@ public record TachographEsperPotentialInVehicleTripIntervalEvent(
String vehicleKey, String vehicleKey,
int containedPotentialInVehicleOvernightStayIntervalCount, int containedPotentialInVehicleOvernightStayIntervalCount,
long containedPotentialInVehicleOvernightStayDurationSeconds, long containedPotentialInVehicleOvernightStayDurationSeconds,
long containedCardPresentDurationSeconds, long containedCardAbsentDurationSeconds,
long containedUnknownDurationSeconds,
OffsetDateTime firstPotentialInVehicleOvernightStayStartedAt, OffsetDateTime firstPotentialInVehicleOvernightStayStartedAt,
OffsetDateTime lastPotentialInVehicleOvernightStayEndedAt, OffsetDateTime lastPotentialInVehicleOvernightStayEndedAt,
String firstPreviousDrivingSourceIntervalId, String firstPreviousDrivingSourceIntervalId,

View File

@ -0,0 +1,10 @@
package at.procon.eventhub.tachographfilesession.service;
import java.util.UUID;
public class DriverNotFoundInCompositeSessionException extends RuntimeException {
public DriverNotFoundInCompositeSessionException(UUID compositeSessionId, String driverKey) {
super("Driver '%s' was not found in tachograph composite session '%s'.".formatted(driverKey, compositeSessionId));
}
}

View File

@ -997,10 +997,8 @@ public class DriverTimelineBuilder {
(OffsetDateTime) event.get("startedAt"), (OffsetDateTime) event.get("startedAt"),
(OffsetDateTime) event.get("endedAt"), (OffsetDateTime) event.get("endedAt"),
(Long) event.get("durationSeconds"), (Long) event.get("durationSeconds"),
0L, (Long) event.get("cardAbsentDurationSeconds"),
0.0d, (Double) event.get("cardAbsentCoveragePercent"),
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"), (String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"), (String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"), (String) event.get("previousRegistrationKey"),

View File

@ -35,6 +35,7 @@ import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer; import java.util.function.Consumer;
@ -76,6 +77,54 @@ public class DriverTimelineReusableProjectionBuilder {
); );
} }
public TachographEsperDrivingDerivedProjectionBundle buildEsperDrivingDerivedProjectionBundle(
TachographFileSession session,
int significantDrivingMinutes,
int minimumRestPeriodMinutes
) {
if (session == null || session.driversByKey() == null || session.driversByKey().isEmpty()) {
return emptyBundle();
}
List<Map<String, Object>> activityInputEvents = new ArrayList<>();
List<Map<String, Object>> vehicleUsageInputEvents = new ArrayList<>();
List<Map<String, Object>> supportGeoInputEvents = new ArrayList<>();
for (DriverExtractionSession driverSession : session.driversByKey().values()) {
if (driverSession == null || driverSession.driverKey() == null) {
continue;
}
ResolvedDriverTimeline timeline = driverTimelineBuilder.build(session, driverSession);
if (timeline == null) {
continue;
}
for (ResolvedActivityInterval interval : safeList(timeline.activityIntervals())) {
activityInputEvents.add(toActivityIntervalInputMap(
session.sessionId(),
driverSession.driverKey(),
interval
));
}
for (ResolvedVehicleUsageInterval interval : safeList(timeline.vehicleUsageIntervals())) {
vehicleUsageInputEvents.add(toVehicleUsageIntervalInputMap(interval));
}
for (ExtractedSupportEvent supportEvent : safeList(timeline.supportEvents())) {
Map<String, Object> supportGeoEvidence = toSupportGeoEvidenceInputMap(session.sessionId(), supportEvent);
if (supportGeoEvidence != null) {
supportGeoInputEvents.add(supportGeoEvidence);
}
}
}
return buildEsperDrivingDerivedProjectionBundle(
activityInputEvents,
vehicleUsageInputEvents,
supportGeoInputEvents,
significantDrivingMinutes,
minimumRestPeriodMinutes
);
}
public TachographEsperDrivingDerivedProjectionBundle buildEsperDrivingDerivedProjectionBundle( public TachographEsperDrivingDerivedProjectionBundle buildEsperDrivingDerivedProjectionBundle(
UUID sessionId, UUID sessionId,
String driverKey, String driverKey,
@ -87,27 +136,23 @@ public class DriverTimelineReusableProjectionBuilder {
return emptyBundle(); return emptyBundle();
} }
return buildEsperDrivingDerivedProjectionBundle( return buildEsperDrivingDerivedProjectionBundle(
sessionId, buildActivityIntervalInputEvents(sessionId, driverKey, timeline.activityIntervals()),
driverKey, buildVehicleUsageIntervalInputEvents(timeline.vehicleUsageIntervals()),
timeline.activityIntervals(), buildSupportGeoInputEvents(sessionId, timeline.supportEvents()),
timeline.vehicleUsageIntervals(),
timeline.supportEvents(),
significantDrivingMinutes, significantDrivingMinutes,
minimumRestPeriodMinutes minimumRestPeriodMinutes
); );
} }
private TachographEsperDrivingDerivedProjectionBundle buildEsperDrivingDerivedProjectionBundle( private TachographEsperDrivingDerivedProjectionBundle buildEsperDrivingDerivedProjectionBundle(
UUID sessionId, List<Map<String, Object>> activityInputEvents,
String driverKey, List<Map<String, Object>> vehicleUsageInputEvents,
List<ResolvedActivityInterval> activityIntervals, List<Map<String, Object>> supportGeoInputEvents,
List<ResolvedVehicleUsageInterval> vehicleUsageIntervals,
List<ExtractedSupportEvent> supportEvents,
int significantDrivingMinutes, int significantDrivingMinutes,
int minimumRestPeriodMinutes int minimumRestPeriodMinutes
) { ) {
if ((activityIntervals == null || activityIntervals.isEmpty()) if ((activityInputEvents == null || activityInputEvents.isEmpty())
&& (vehicleUsageIntervals == null || vehicleUsageIntervals.isEmpty())) { && (vehicleUsageInputEvents == null || vehicleUsageInputEvents.isEmpty())) {
return emptyBundle(); return emptyBundle();
} }
@ -149,29 +194,26 @@ public class DriverTimelineReusableProjectionBuilder {
"potentialInVehicleTripIntervals", newData -> collectPotentialInVehicleTripIntervalEvents(newData, potentialInVehicleTripIntervals) "potentialInVehicleTripIntervals", newData -> collectPotentialInVehicleTripIntervalEvents(newData, potentialInVehicleTripIntervals)
), ),
runtime -> { runtime -> {
if (supportEvents != null) { if (supportGeoInputEvents != null) {
for (ExtractedSupportEvent supportEvent : supportEvents) { for (Map<String, Object> supportGeoEvidence : supportGeoInputEvents) {
Map<String, Object> supportGeoEvidence = toSupportGeoEvidenceInputMap(sessionId, supportEvent);
if (supportGeoEvidence != null) {
runtime.getEventService().sendEventMap( runtime.getEventService().sendEventMap(
supportGeoEvidence, supportGeoEvidence,
"TachographSupportGeoEvidenceInputEvent" "TachographSupportGeoEvidenceInputEvent"
); );
} }
} }
} if (vehicleUsageInputEvents != null) {
if (vehicleUsageIntervals != null) { for (Map<String, Object> interval : vehicleUsageInputEvents) {
for (ResolvedVehicleUsageInterval interval : vehicleUsageIntervals) {
runtime.getEventService().sendEventMap( runtime.getEventService().sendEventMap(
toVehicleUsageIntervalInputMap(interval), interval,
"TachographVehicleUsageIntervalInputEvent" "TachographVehicleUsageIntervalInputEvent"
); );
} }
} }
if (activityIntervals != null) { if (activityInputEvents != null) {
for (ResolvedActivityInterval interval : activityIntervals) { for (Map<String, Object> interval : activityInputEvents) {
runtime.getEventService().sendEventMap( runtime.getEventService().sendEventMap(
toActivityIntervalInputMap(sessionId, driverKey, interval), interval,
"TachographActivityIntervalInputEvent" "TachographActivityIntervalInputEvent"
); );
} }
@ -192,6 +234,50 @@ public class DriverTimelineReusableProjectionBuilder {
); );
} }
private List<Map<String, Object>> buildActivityIntervalInputEvents(
UUID sessionId,
String driverKey,
List<ResolvedActivityInterval> activityIntervals
) {
return safeList(activityIntervals).stream()
.map(interval -> toActivityIntervalInputMap(sessionId, driverKey, interval))
.sorted(Comparator
.comparing((Map<String, Object> event) -> (Long) event.get("startedAtEpochSecond"))
.thenComparing(event -> Objects.toString(event.get("driverKey"), ""))
.thenComparing(event -> Objects.toString(event.get("intervalId"), "")))
.toList();
}
private List<Map<String, Object>> buildVehicleUsageIntervalInputEvents(
List<ResolvedVehicleUsageInterval> vehicleUsageIntervals
) {
return safeList(vehicleUsageIntervals).stream()
.map(this::toVehicleUsageIntervalInputMap)
.sorted(Comparator
.comparing((Map<String, Object> event) -> (Long) event.get("startedAtEpochSecond"))
.thenComparing(event -> Objects.toString(event.get("driverKey"), ""))
.thenComparing(event -> Objects.toString(event.get("intervalId"), "")))
.toList();
}
private List<Map<String, Object>> buildSupportGeoInputEvents(
UUID sessionId,
List<ExtractedSupportEvent> supportEvents
) {
return safeList(supportEvents).stream()
.map(event -> toSupportGeoEvidenceInputMap(sessionId, event))
.filter(Objects::nonNull)
.sorted(Comparator
.comparing((Map<String, Object> event) -> (Long) event.get("occurredAtEpochSecond"))
.thenComparing(event -> Objects.toString(event.get("driverKey"), ""))
.thenComparing(event -> Objects.toString(event.get("eventId"), "")))
.toList();
}
private <T> List<T> safeList(List<T> values) {
return values == null ? List.of() : values;
}
private TachographEsperDrivingDerivedProjectionBundle emptyBundle() { private TachographEsperDrivingDerivedProjectionBundle emptyBundle() {
return new TachographEsperDrivingDerivedProjectionBundle( return new TachographEsperDrivingDerivedProjectionBundle(
List.of(), List.of(),
@ -503,10 +589,8 @@ public class DriverTimelineReusableProjectionBuilder {
OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC), OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC), OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC),
(Long) event.get("durationSeconds"), (Long) event.get("durationSeconds"),
(Long) event.get("cardPresentDurationSeconds"), (Long) event.get("cardAbsentDurationSeconds"),
(Double) event.get("cardPresentCoveragePercent"), (Double) event.get("cardAbsentCoveragePercent"),
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"), (String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"), (String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"), (String) event.get("previousRegistrationKey"),
@ -571,10 +655,8 @@ public class DriverTimelineReusableProjectionBuilder {
OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC), OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC), OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC),
(Long) event.get("durationSeconds"), (Long) event.get("durationSeconds"),
(Long) event.get("cardPresentDurationSeconds"), (Long) event.get("cardAbsentDurationSeconds"),
(Double) event.get("cardPresentCoveragePercent"), (Double) event.get("cardAbsentCoveragePercent"),
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"), (String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"), (String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"), (String) event.get("previousRegistrationKey"),
@ -639,10 +721,8 @@ public class DriverTimelineReusableProjectionBuilder {
OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC), OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC), OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC),
(Long) event.get("durationSeconds"), (Long) event.get("durationSeconds"),
(Long) event.get("cardPresentDurationSeconds"), (Long) event.get("cardAbsentDurationSeconds"),
(Double) event.get("cardPresentCoveragePercent"), (Double) event.get("cardAbsentCoveragePercent"),
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"), (String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"), (String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"), (String) event.get("previousRegistrationKey"),
@ -693,8 +773,7 @@ public class DriverTimelineReusableProjectionBuilder {
(String) event.get("vehicleKey"), (String) event.get("vehicleKey"),
(Integer) event.get("containedPotentialInVehicleOvernightStayIntervalCount"), (Integer) event.get("containedPotentialInVehicleOvernightStayIntervalCount"),
(Long) event.get("containedPotentialInVehicleOvernightStayDurationSeconds"), (Long) event.get("containedPotentialInVehicleOvernightStayDurationSeconds"),
(Long) event.get("containedCardPresentDurationSeconds"), (Long) event.get("containedCardAbsentDurationSeconds"),
(Long) event.get("containedUnknownDurationSeconds"),
OffsetDateTime.ofInstant(Instant.ofEpochSecond((Long) event.get("firstPotentialInVehicleOvernightStayStartedAtEpochSecond")), ZoneOffset.UTC), OffsetDateTime.ofInstant(Instant.ofEpochSecond((Long) event.get("firstPotentialInVehicleOvernightStayStartedAtEpochSecond")), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond((Long) event.get("lastPotentialInVehicleOvernightStayEndedAtEpochSecond")), ZoneOffset.UTC), OffsetDateTime.ofInstant(Instant.ofEpochSecond((Long) event.get("lastPotentialInVehicleOvernightStayEndedAtEpochSecond")), ZoneOffset.UTC),
(String) event.get("firstPreviousDrivingSourceIntervalId"), (String) event.get("firstPreviousDrivingSourceIntervalId"),
@ -725,7 +804,7 @@ public class DriverTimelineReusableProjectionBuilder {
private List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> sortDailyWeeklyRestCandidateCoverageIntervals( private List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> sortDailyWeeklyRestCandidateCoverageIntervals(
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> intervals List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> intervals
) { ) {
return intervals.stream() return deduplicateRestCoverageIntervals(intervals).stream()
.sorted(Comparator.comparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::startedAt) .sorted(Comparator.comparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::startedAt)
.thenComparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::endedAt)) .thenComparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::endedAt))
.toList(); .toList();
@ -734,7 +813,7 @@ public class DriverTimelineReusableProjectionBuilder {
private List<TachographEsperPotentialHomeOvernightStayIntervalEvent> sortPotentialHomeOvernightStayIntervals( private List<TachographEsperPotentialHomeOvernightStayIntervalEvent> sortPotentialHomeOvernightStayIntervals(
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> intervals List<TachographEsperPotentialHomeOvernightStayIntervalEvent> intervals
) { ) {
return intervals.stream() return deduplicatePotentialHomeOvernightStayIntervals(intervals).stream()
.sorted(Comparator.comparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::startedAt) .sorted(Comparator.comparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::endedAt)) .thenComparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::endedAt))
.toList(); .toList();
@ -743,12 +822,76 @@ public class DriverTimelineReusableProjectionBuilder {
private List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> sortPotentialInVehicleOvernightStayIntervals( private List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> sortPotentialInVehicleOvernightStayIntervals(
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> intervals List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> intervals
) { ) {
return intervals.stream() return deduplicatePotentialInVehicleOvernightStayIntervals(intervals).stream()
.sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt) .sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt)) .thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt))
.toList(); .toList();
} }
private List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> deduplicateRestCoverageIntervals(
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> intervals
) {
Map<String, TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> deduplicated = new LinkedHashMap<>();
for (TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent interval : intervals) {
deduplicated.put(restCoverageIntervalKey(
interval.driverKey(),
interval.startedAt(),
interval.endedAt(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId()
), interval);
}
return new ArrayList<>(deduplicated.values());
}
private List<TachographEsperPotentialHomeOvernightStayIntervalEvent> deduplicatePotentialHomeOvernightStayIntervals(
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> intervals
) {
Map<String, TachographEsperPotentialHomeOvernightStayIntervalEvent> deduplicated = new LinkedHashMap<>();
for (TachographEsperPotentialHomeOvernightStayIntervalEvent interval : intervals) {
deduplicated.put(restCoverageIntervalKey(
interval.driverKey(),
interval.startedAt(),
interval.endedAt(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId()
), interval);
}
return new ArrayList<>(deduplicated.values());
}
private List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> deduplicatePotentialInVehicleOvernightStayIntervals(
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> intervals
) {
Map<String, TachographEsperPotentialInVehicleOvernightStayIntervalEvent> deduplicated = new LinkedHashMap<>();
for (TachographEsperPotentialInVehicleOvernightStayIntervalEvent interval : intervals) {
deduplicated.put(restCoverageIntervalKey(
interval.driverKey(),
interval.startedAt(),
interval.endedAt(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId()
), interval);
}
return new ArrayList<>(deduplicated.values());
}
private String restCoverageIntervalKey(
String driverKey,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
String previousDrivingSourceIntervalId,
String nextDrivingSourceIntervalId
) {
return String.join("|",
driverKey == null ? "" : driverKey,
startedAt == null ? "" : startedAt.toString(),
endedAt == null ? "" : endedAt.toString(),
previousDrivingSourceIntervalId == null ? "" : previousDrivingSourceIntervalId,
nextDrivingSourceIntervalId == null ? "" : nextDrivingSourceIntervalId
);
}
private List<TachographEsperPotentialInVehicleTripIntervalEvent> sortPotentialInVehicleTripIntervals( private List<TachographEsperPotentialInVehicleTripIntervalEvent> sortPotentialInVehicleTripIntervals(
List<TachographEsperPotentialInVehicleTripIntervalEvent> intervals List<TachographEsperPotentialInVehicleTripIntervalEvent> intervals
) { ) {

View File

@ -0,0 +1,51 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.tachographfilesession.model.TachographCompositeSession;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository;
@Repository
public class InMemoryTachographCompositeSessionRepository implements TachographCompositeSessionRepository {
private final Map<UUID, TachographCompositeSession> sessionsById = new LinkedHashMap<>();
@Override
public synchronized TachographCompositeSession save(TachographCompositeSession session) {
TachographCompositeSession toSave = session.createdAt() == null
? new TachographCompositeSession(
session.compositeSessionId(),
session.tenantKey(),
session.label(),
session.memberSessionIds(),
Instant.now()
)
: session;
sessionsById.put(toSave.compositeSessionId(), toSave);
return toSave;
}
@Override
public synchronized Optional<TachographCompositeSession> find(UUID compositeSessionId) {
return Optional.ofNullable(sessionsById.get(compositeSessionId));
}
@Override
public synchronized boolean delete(UUID compositeSessionId) {
return sessionsById.remove(compositeSessionId) != null;
}
@Override
public synchronized List<TachographCompositeSession> list() {
List<TachographCompositeSession> result = new ArrayList<>(sessionsById.values());
result.sort(Comparator.comparing(TachographCompositeSession::createdAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(TachographCompositeSession::compositeSessionId));
return List.copyOf(result);
}
}

View File

@ -0,0 +1,10 @@
package at.procon.eventhub.tachographfilesession.service;
import java.util.UUID;
public class TachographCompositeSessionNotFoundException extends RuntimeException {
public TachographCompositeSessionNotFoundException(UUID compositeSessionId) {
super("Tachograph composite session '%s' was not found.".formatted(compositeSessionId));
}
}

View File

@ -0,0 +1,17 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.tachographfilesession.model.TachographCompositeSession;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface TachographCompositeSessionRepository {
TachographCompositeSession save(TachographCompositeSession session);
Optional<TachographCompositeSession> find(UUID compositeSessionId);
boolean delete(UUID compositeSessionId);
List<TachographCompositeSession> list();
}

View File

@ -0,0 +1,261 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.processing.service.UnifiedEventTimelineReconstructor;
import at.procon.eventhub.service.EventAcquisitionRecordKeyService;
import at.procon.eventhub.service.EventHubEventSorter;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographCompositeSessionRequest;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographCompositeSessionResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeDriverEventsResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeSessionListDriversResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto;
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriver;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.TachographCompositeSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import java.time.Instant;
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.UUID;
import org.springframework.stereotype.Service;
@Service
public class TachographCompositeSessionService {
private final TachographCompositeSessionRepository compositeRepository;
private final TachographFileSessionRepository fileSessionRepository;
private final DriverTimelineEventBuilder driverTimelineEventBuilder;
private final UnifiedEventTimelineReconstructor timelineReconstructor;
private final EventAcquisitionRecordKeyService eventKeyService;
private final EventHubEventSorter eventSorter;
public TachographCompositeSessionService(
TachographCompositeSessionRepository compositeRepository,
TachographFileSessionRepository fileSessionRepository,
DriverTimelineEventBuilder driverTimelineEventBuilder,
UnifiedEventTimelineReconstructor timelineReconstructor,
EventAcquisitionRecordKeyService eventKeyService,
EventHubEventSorter eventSorter
) {
this.compositeRepository = compositeRepository;
this.fileSessionRepository = fileSessionRepository;
this.driverTimelineEventBuilder = driverTimelineEventBuilder;
this.timelineReconstructor = timelineReconstructor;
this.eventKeyService = eventKeyService;
this.eventSorter = eventSorter;
}
public CreateTachographCompositeSessionResponse createCompositeSession(CreateTachographCompositeSessionRequest request) {
if (request == null || request.sessionIds() == null || request.sessionIds().isEmpty()) {
throw new IllegalArgumentException("sessionIds must not be empty.");
}
List<UUID> memberSessionIds = request.sessionIds().stream()
.filter(Objects::nonNull)
.distinct()
.toList();
if (memberSessionIds.isEmpty()) {
throw new IllegalArgumentException("sessionIds must not be empty.");
}
List<TachographFileSession> sessions = memberSessions(memberSessionIds);
String tenantKey = sessions.stream()
.map(session -> session.metadata() == null ? null : session.metadata().tenantKey())
.filter(value -> value != null && !value.isBlank())
.findFirst()
.orElse("default");
TachographCompositeSession compositeSession = compositeRepository.save(new TachographCompositeSession(
UUID.randomUUID(),
tenantKey,
blankToNull(request.label()),
memberSessionIds,
Instant.now()
));
return new CreateTachographCompositeSessionResponse(toSummary(compositeSession, sessions));
}
public TachographCompositeSessionSummaryDto getCompositeSession(UUID compositeSessionId) {
TachographCompositeSession compositeSession = requireCompositeSession(compositeSessionId);
return toSummary(compositeSession, memberSessions(compositeSession.memberSessionIds()));
}
public TachographCompositeSessionListDriversResponse listDrivers(UUID compositeSessionId) {
TachographCompositeSession compositeSession = requireCompositeSession(compositeSessionId);
return new TachographCompositeSessionListDriversResponse(
compositeSessionId,
aggregateDriverSummaries(memberSessions(compositeSession.memberSessionIds()))
);
}
public TachographCompositeDriverEventsResponse getMergedDriverEvents(UUID compositeSessionId, String driverKey) {
TachographCompositeSession compositeSession = requireCompositeSession(compositeSessionId);
List<TachographFileSession> memberSessions = memberSessions(compositeSession.memberSessionIds());
List<TachographFileSession> sourceSessions = sessionsWithDriver(memberSessions, driverKey);
if (sourceSessions.isEmpty()) {
throw new DriverNotFoundInCompositeSessionException(compositeSessionId, driverKey);
}
List<EventHubEventDto> mergedEvents = mergeDriverEvents(sourceSessions, driverKey);
return new TachographCompositeDriverEventsResponse(
compositeSessionId,
driverKey,
sourceSessions.stream().map(TachographFileSession::sessionId).toList(),
mergedEvents.size(),
mergedEvents
);
}
public ResolvedDriverTimeline getMergedDriverTimeline(UUID compositeSessionId, String driverKey) {
TachographCompositeSession compositeSession = requireCompositeSession(compositeSessionId);
List<TachographFileSession> memberSessions = memberSessions(compositeSession.memberSessionIds());
List<TachographFileSession> sourceSessions = sessionsWithDriver(memberSessions, driverKey);
if (sourceSessions.isEmpty()) {
throw new DriverNotFoundInCompositeSessionException(compositeSessionId, driverKey);
}
List<EventHubEventDto> mergedEvents = mergeDriverEvents(sourceSessions, driverKey);
return timelineReconstructor.reconstruct(
compositeSessionId,
driverKey,
mergedEvents,
mergeWarnings(sourceSessions, driverKey),
"COMPOSITE_TACHOGRAPH_FILE_SESSION"
);
}
private TachographCompositeSession requireCompositeSession(UUID compositeSessionId) {
return compositeRepository.find(compositeSessionId)
.orElseThrow(() -> new TachographCompositeSessionNotFoundException(compositeSessionId));
}
private List<TachographFileSession> memberSessions(List<UUID> memberSessionIds) {
List<TachographFileSession> sessions = new ArrayList<>();
for (UUID sessionId : memberSessionIds) {
sessions.add(fileSessionRepository.find(sessionId)
.orElseThrow(() -> new TachographFileSessionNotFoundException(sessionId)));
}
return List.copyOf(sessions);
}
private List<TachographFileSession> sessionsWithDriver(List<TachographFileSession> sessions, String driverKey) {
return sessions.stream()
.filter(session -> session.driversByKey() != null && session.driversByKey().containsKey(driverKey))
.toList();
}
private List<EventHubEventDto> mergeDriverEvents(List<TachographFileSession> sessions, String driverKey) {
LinkedHashMap<String, EventHubEventDto> bySignature = new LinkedHashMap<>();
for (TachographFileSession session : sessions) {
DriverExtractionSession driverSession = session.driversByKey().get(driverKey);
if (driverSession == null) {
continue;
}
for (EventHubEventDto event : driverTimelineEventBuilder.buildEvents(session, driverSession)) {
bySignature.putIfAbsent(eventKeyService.buildEventSignatureHash(event), event);
}
}
return eventSorter.sort(new ArrayList<>(bySignature.values()));
}
private List<ExtractionWarning> mergeWarnings(List<TachographFileSession> sessions, String driverKey) {
LinkedHashSet<ExtractionWarning> warnings = new LinkedHashSet<>();
for (TachographFileSession session : sessions) {
if (session.warnings() != null) {
warnings.addAll(session.warnings());
}
DriverExtractionSession driverSession = session.driversByKey().get(driverKey);
if (driverSession != null && driverSession.warnings() != null) {
warnings.addAll(driverSession.warnings());
}
}
return List.copyOf(warnings);
}
private TachographCompositeSessionSummaryDto toSummary(
TachographCompositeSession compositeSession,
List<TachographFileSession> memberSessions
) {
return new TachographCompositeSessionSummaryDto(
compositeSession.compositeSessionId(),
compositeSession.tenantKey(),
compositeSession.label(),
compositeSession.memberSessionIds(),
aggregateDriverSummaries(memberSessions),
compositeSession.createdAt()
);
}
private List<TachographFileDriverSummaryDto> aggregateDriverSummaries(List<TachographFileSession> sessions) {
Map<String, DriverSummaryAccumulator> byDriverKey = new LinkedHashMap<>();
for (TachographFileSession session : sessions) {
if (session.driversByKey() == null) {
continue;
}
for (DriverExtractionSession driverSession : session.driversByKey().values()) {
if (driverSession == null || driverSession.driverKey() == null) {
continue;
}
byDriverKey.computeIfAbsent(driverSession.driverKey(), ignored -> new DriverSummaryAccumulator(driverSession.driverKey()))
.accept(driverSession);
}
}
return byDriverKey.values().stream()
.map(DriverSummaryAccumulator::finish)
.sorted(Comparator.comparing(TachographFileDriverSummaryDto::driverKey))
.toList();
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private static final class DriverSummaryAccumulator {
private final String driverKey;
private String surname;
private String firstNames;
private String cardNation;
private String cardNumber;
private int activityIntervalCount;
private int cardVehicleUsageIntervalCount;
private DriverSummaryAccumulator(String driverKey) {
this.driverKey = driverKey;
}
private void accept(DriverExtractionSession session) {
ExtractedDriver driver = session.driver();
ExtractedDriverCard card = session.driverCard();
if (surname == null && driver != null) {
surname = driver.surname();
}
if (firstNames == null && driver != null) {
firstNames = driver.firstNames();
}
if (cardNation == null && card != null) {
cardNation = card.cardNation();
}
if (cardNumber == null && card != null) {
cardNumber = card.cardNumber();
}
activityIntervalCount += session.cardActivityIntervals() == null ? 0 : session.cardActivityIntervals().size();
cardVehicleUsageIntervalCount += session.cardVehicleUsageIntervals() == null ? 0 : session.cardVehicleUsageIntervals().size();
}
private TachographFileDriverSummaryDto finish() {
return new TachographFileDriverSummaryDto(
driverKey,
surname,
firstNames,
cardNation,
cardNumber,
activityIntervalCount,
cardVehicleUsageIntervalCount
);
}
}
}

View File

@ -526,25 +526,6 @@ public class TachographFileSessionProcessingService {
return null; return null;
} }
long durationSeconds = Duration.between(start, end).getSeconds(); long durationSeconds = Duration.between(start, end).getSeconds();
long cardPresentDurationSeconds = overlapVehicleUsageSeconds(
start,
end,
rawVehicleUsageIntervals,
interval.driverKey(),
null
);
double cardPresentCoveragePercent = durationSeconds == 0L
? 0.0d
: (cardPresentDurationSeconds * 100.0d) / durationSeconds;
long unknownDurationSeconds = overlapSeconds(
start,
end,
rawVuCardAbsentIntervals,
interval.driverKey()
);
double unknownCoveragePercent = durationSeconds == 0L
? 0.0d
: (unknownDurationSeconds * 100.0d) / durationSeconds;
boolean beginBoundaryChanged = !start.equals(interval.startedAt()); boolean beginBoundaryChanged = !start.equals(interval.startedAt());
boolean endBoundaryChanged = !end.equals(interval.endedAt()); boolean endBoundaryChanged = !end.equals(interval.endedAt());
return new TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent( return new TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent(
@ -553,16 +534,16 @@ public class TachographFileSessionProcessingService {
start, start,
end, end,
durationSeconds, durationSeconds,
cardPresentDurationSeconds, interval.cardAbsentDurationSeconds(),
cardPresentCoveragePercent, interval.cardAbsentCoveragePercent(),
unknownDurationSeconds,
unknownCoveragePercent,
interval.previousDrivingSourceIntervalId(), interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(), interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(), interval.previousRegistrationKey(),
interval.nextRegistrationKey(), interval.nextRegistrationKey(),
interval.previousVehicleKey(), interval.previousVehicleKey(),
interval.nextVehicleKey(), interval.nextVehicleKey(),
beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(),
endBoundaryChanged ? null : interval.endBoundaryOdometerKm(),
beginBoundaryChanged ? null : interval.beginGeoEventId(), beginBoundaryChanged ? null : interval.beginGeoEventId(),
beginBoundaryChanged ? null : interval.beginGeoEventDomain(), beginBoundaryChanged ? null : interval.beginGeoEventDomain(),
beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), beginBoundaryChanged ? null : interval.beginGeoOccurredAt(),
@ -607,25 +588,6 @@ public class TachographFileSessionProcessingService {
return null; return null;
} }
long durationSeconds = Duration.between(start, end).getSeconds(); long durationSeconds = Duration.between(start, end).getSeconds();
long cardPresentDurationSeconds = overlapVehicleUsageSeconds(
start,
end,
rawVehicleUsageIntervals,
interval.driverKey(),
null
);
double cardPresentCoveragePercent = durationSeconds == 0L
? 0.0d
: (cardPresentDurationSeconds * 100.0d) / durationSeconds;
long unknownDurationSeconds = overlapSeconds(
start,
end,
rawVuCardAbsentIntervals,
interval.driverKey()
);
double unknownCoveragePercent = durationSeconds == 0L
? 0.0d
: (unknownDurationSeconds * 100.0d) / durationSeconds;
boolean beginBoundaryChanged = !start.equals(interval.startedAt()); boolean beginBoundaryChanged = !start.equals(interval.startedAt());
boolean endBoundaryChanged = !end.equals(interval.endedAt()); boolean endBoundaryChanged = !end.equals(interval.endedAt());
return new TachographEsperPotentialHomeOvernightStayIntervalEvent( return new TachographEsperPotentialHomeOvernightStayIntervalEvent(
@ -634,16 +596,16 @@ public class TachographFileSessionProcessingService {
start, start,
end, end,
durationSeconds, durationSeconds,
cardPresentDurationSeconds, interval.cardAbsentDurationSeconds(),
cardPresentCoveragePercent, interval.cardAbsentCoveragePercent(),
unknownDurationSeconds,
unknownCoveragePercent,
interval.previousDrivingSourceIntervalId(), interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(), interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(), interval.previousRegistrationKey(),
interval.nextRegistrationKey(), interval.nextRegistrationKey(),
interval.previousVehicleKey(), interval.previousVehicleKey(),
interval.nextVehicleKey(), interval.nextVehicleKey(),
beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(),
endBoundaryChanged ? null : interval.endBoundaryOdometerKm(),
beginBoundaryChanged ? null : interval.beginGeoEventId(), beginBoundaryChanged ? null : interval.beginGeoEventId(),
beginBoundaryChanged ? null : interval.beginGeoEventDomain(), beginBoundaryChanged ? null : interval.beginGeoEventDomain(),
beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), beginBoundaryChanged ? null : interval.beginGeoOccurredAt(),
@ -688,25 +650,6 @@ public class TachographFileSessionProcessingService {
return null; return null;
} }
long durationSeconds = Duration.between(start, end).getSeconds(); long durationSeconds = Duration.between(start, end).getSeconds();
long cardPresentDurationSeconds = overlapVehicleUsageSeconds(
start,
end,
rawVehicleUsageIntervals,
interval.driverKey(),
null
);
double cardPresentCoveragePercent = durationSeconds == 0L
? 0.0d
: (cardPresentDurationSeconds * 100.0d) / durationSeconds;
long unknownDurationSeconds = overlapSeconds(
start,
end,
rawVuCardAbsentIntervals,
interval.driverKey()
);
double unknownCoveragePercent = durationSeconds == 0L
? 0.0d
: (unknownDurationSeconds * 100.0d) / durationSeconds;
boolean beginBoundaryChanged = !start.equals(interval.startedAt()); boolean beginBoundaryChanged = !start.equals(interval.startedAt());
boolean endBoundaryChanged = !end.equals(interval.endedAt()); boolean endBoundaryChanged = !end.equals(interval.endedAt());
return new TachographEsperPotentialInVehicleOvernightStayIntervalEvent( return new TachographEsperPotentialInVehicleOvernightStayIntervalEvent(
@ -715,16 +658,16 @@ public class TachographFileSessionProcessingService {
start, start,
end, end,
durationSeconds, durationSeconds,
cardPresentDurationSeconds, interval.cardAbsentDurationSeconds(),
cardPresentCoveragePercent, interval.cardAbsentCoveragePercent(),
unknownDurationSeconds,
unknownCoveragePercent,
interval.previousDrivingSourceIntervalId(), interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(), interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(), interval.previousRegistrationKey(),
interval.nextRegistrationKey(), interval.nextRegistrationKey(),
interval.previousVehicleKey(), interval.previousVehicleKey(),
interval.nextVehicleKey(), interval.nextVehicleKey(),
beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(),
endBoundaryChanged ? null : interval.endBoundaryOdometerKm(),
beginBoundaryChanged ? null : interval.beginGeoEventId(), beginBoundaryChanged ? null : interval.beginGeoEventId(),
beginBoundaryChanged ? null : interval.beginGeoEventDomain(), beginBoundaryChanged ? null : interval.beginGeoEventDomain(),
beginBoundaryChanged ? null : interval.beginGeoOccurredAt(), beginBoundaryChanged ? null : interval.beginGeoOccurredAt(),
@ -803,10 +746,7 @@ public class TachographFileSessionProcessingService {
.mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::durationSeconds) .mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::durationSeconds)
.sum(), .sum(),
containedIntervals.stream() containedIntervals.stream()
.mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::cardPresentDurationSeconds) .mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::cardAbsentDurationSeconds)
.sum(),
containedIntervals.stream()
.mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::unknownDurationSeconds)
.sum(), .sum(),
first.startedAt(), first.startedAt(),
last.endedAt(), last.endedAt(),
@ -1409,7 +1349,7 @@ public class TachographFileSessionProcessingService {
"Driving interruption intervals are gaps between consecutive driving intervals longer than the configured significant-driving threshold.", "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.", "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 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 unknown-coverage metrics computed from vehicle-usage and VU card-absent overlap.", "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.", "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.", "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.", "If both begin and end geo evidence carry odometer values, geoEvidenceMovementCategory classifies the interval as STATIONARY, MINOR, MOVED, or UNKNOWN.",

View File

@ -72,7 +72,7 @@ create schema DailyWeeklyRestCandidateCoverageUnknownResolvedInterval(
startedAtEpochSecond long, startedAtEpochSecond long,
endedAtEpochSecond long, endedAtEpochSecond long,
durationSeconds long, durationSeconds long,
unknownDurationSeconds long, cardAbsentDurationSeconds long,
previousDrivingSourceIntervalId string, previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string, nextDrivingSourceIntervalId string,
previousRegistrationKey string, previousRegistrationKey string,
@ -87,8 +87,7 @@ create schema DailyWeeklyRestCandidateCoverageCardResolvedInterval(
startedAtEpochSecond long, startedAtEpochSecond long,
endedAtEpochSecond long, endedAtEpochSecond long,
durationSeconds long, durationSeconds long,
cardPresentDurationSeconds long, cardAbsentDurationSeconds long,
unknownDurationSeconds long,
previousDrivingSourceIntervalId string, previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string, nextDrivingSourceIntervalId string,
previousRegistrationKey string, previousRegistrationKey string,
@ -103,10 +102,8 @@ create schema DailyWeeklyRestCandidateCoverageInterval(
startedAtEpochSecond long, startedAtEpochSecond long,
endedAtEpochSecond long, endedAtEpochSecond long,
durationSeconds long, durationSeconds long,
cardPresentDurationSeconds long, cardAbsentDurationSeconds long,
cardPresentCoveragePercent double, cardAbsentCoveragePercent double,
unknownDurationSeconds long,
unknownCoveragePercent double,
previousDrivingSourceIntervalId string, previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string, nextDrivingSourceIntervalId string,
previousRegistrationKey string, previousRegistrationKey string,
@ -277,6 +274,12 @@ create schema DailyWeeklyRestCandidateCoverageFinalizationRequest(
endedAtEpochSecond long endedAtEpochSecond long
); );
create schema DailyWeeklyRestCandidateCoverageEmittedKey(
driverKey string,
startedAtEpochSecond long,
endedAtEpochSecond long
);
create context PerDriver partition by driverKey from TachographVehicleUsageIntervalInputEvent; create context PerDriver partition by driverKey from TachographVehicleUsageIntervalInputEvent;
create schema VuCardAbsentInterval( create schema VuCardAbsentInterval(
@ -299,10 +302,8 @@ create schema PotentialHomeOvernightStayInterval(
startedAtEpochSecond long, startedAtEpochSecond long,
endedAtEpochSecond long, endedAtEpochSecond long,
durationSeconds long, durationSeconds long,
cardPresentDurationSeconds long, cardAbsentDurationSeconds long,
cardPresentCoveragePercent double, cardAbsentCoveragePercent double,
unknownDurationSeconds long,
unknownCoveragePercent double,
previousDrivingSourceIntervalId string, previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string, nextDrivingSourceIntervalId string,
previousRegistrationKey string, previousRegistrationKey string,
@ -335,10 +336,8 @@ create schema PotentialInVehicleOvernightStayInterval(
startedAtEpochSecond long, startedAtEpochSecond long,
endedAtEpochSecond long, endedAtEpochSecond long,
durationSeconds long, durationSeconds long,
cardPresentDurationSeconds long, cardAbsentDurationSeconds long,
cardPresentCoveragePercent double, cardAbsentCoveragePercent double,
unknownDurationSeconds long,
unknownCoveragePercent double,
previousDrivingSourceIntervalId string, previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string, nextDrivingSourceIntervalId string,
previousRegistrationKey string, previousRegistrationKey string,
@ -371,10 +370,8 @@ create schema UnclassifiedDailyWeeklyRestCandidateCoverageInterval(
startedAtEpochSecond long, startedAtEpochSecond long,
endedAtEpochSecond long, endedAtEpochSecond long,
durationSeconds long, durationSeconds long,
cardPresentDurationSeconds long, cardAbsentDurationSeconds long,
cardPresentCoveragePercent double, cardAbsentCoveragePercent double,
unknownDurationSeconds long,
unknownCoveragePercent double,
previousDrivingSourceIntervalId string, previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string, nextDrivingSourceIntervalId string,
previousRegistrationKey string, previousRegistrationKey string,
@ -409,8 +406,7 @@ create schema PotentialInVehicleTripState(
vehicleKey string, vehicleKey string,
containedPotentialInVehicleOvernightStayIntervalCount int, containedPotentialInVehicleOvernightStayIntervalCount int,
containedPotentialInVehicleOvernightStayDurationSeconds long, containedPotentialInVehicleOvernightStayDurationSeconds long,
containedCardPresentDurationSeconds long, containedCardAbsentDurationSeconds long,
containedUnknownDurationSeconds long,
firstPotentialInVehicleOvernightStayStartedAtEpochSecond long, firstPotentialInVehicleOvernightStayStartedAtEpochSecond long,
lastPotentialInVehicleOvernightStayEndedAtEpochSecond long, lastPotentialInVehicleOvernightStayEndedAtEpochSecond long,
firstPreviousDrivingSourceIntervalId string, firstPreviousDrivingSourceIntervalId string,
@ -427,8 +423,7 @@ create schema PotentialInVehicleTripInterval(
vehicleKey string, vehicleKey string,
containedPotentialInVehicleOvernightStayIntervalCount int, containedPotentialInVehicleOvernightStayIntervalCount int,
containedPotentialInVehicleOvernightStayDurationSeconds long, containedPotentialInVehicleOvernightStayDurationSeconds long,
containedCardPresentDurationSeconds long, containedCardAbsentDurationSeconds long,
containedUnknownDurationSeconds long,
firstPotentialInVehicleOvernightStayStartedAtEpochSecond long, firstPotentialInVehicleOvernightStayStartedAtEpochSecond long,
lastPotentialInVehicleOvernightStayEndedAtEpochSecond long, lastPotentialInVehicleOvernightStayEndedAtEpochSecond long,
firstPreviousDrivingSourceIntervalId string, firstPreviousDrivingSourceIntervalId string,
@ -455,6 +450,7 @@ create window DailyWeeklyRestCandidateEndBoundaryOdometerCandidateWindow#keepall
create window DailyWeeklyRestCandidateEndBoundaryOdometerBestScoreWindow#unique(driverKey, startedAtEpochSecond, endedAtEpochSecond) as DailyWeeklyRestCandidateEndBoundaryOdometerBestScore; create window DailyWeeklyRestCandidateEndBoundaryOdometerBestScoreWindow#unique(driverKey, startedAtEpochSecond, endedAtEpochSecond) as DailyWeeklyRestCandidateEndBoundaryOdometerBestScore;
create window DailyWeeklyRestCandidateEndBoundaryOdometerResolvedWindow#unique(driverKey, startedAtEpochSecond, endedAtEpochSecond) as DailyWeeklyRestCandidateEndBoundaryOdometerResolved; create window DailyWeeklyRestCandidateEndBoundaryOdometerResolvedWindow#unique(driverKey, startedAtEpochSecond, endedAtEpochSecond) as DailyWeeklyRestCandidateEndBoundaryOdometerResolved;
create window DailyWeeklyRestCandidateCoverageCardResolvedIntervalWindow#unique(driverKey, startedAtEpochSecond, endedAtEpochSecond) as DailyWeeklyRestCandidateCoverageCardResolvedInterval; create window DailyWeeklyRestCandidateCoverageCardResolvedIntervalWindow#unique(driverKey, startedAtEpochSecond, endedAtEpochSecond) as DailyWeeklyRestCandidateCoverageCardResolvedInterval;
create window DailyWeeklyRestCandidateCoverageEmittedKeyWindow#unique(driverKey, startedAtEpochSecond, endedAtEpochSecond) as DailyWeeklyRestCandidateCoverageEmittedKey;
insert into SupportGeoEvidenceWindow insert into SupportGeoEvidenceWindow
select select
@ -550,7 +546,7 @@ select
then c.endedAtEpochSecond - u.startedAtEpochSecond then c.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond else u.endedAtEpochSecond - u.startedAtEpochSecond
end end
) as unknownDurationSeconds, ) as cardAbsentDurationSeconds,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId, c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId, c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
c.previousRegistrationKey as previousRegistrationKey, c.previousRegistrationKey as previousRegistrationKey,
@ -582,7 +578,7 @@ select
c.startedAtEpochSecond as startedAtEpochSecond, c.startedAtEpochSecond as startedAtEpochSecond,
c.endedAtEpochSecond as endedAtEpochSecond, c.endedAtEpochSecond as endedAtEpochSecond,
c.durationSeconds as durationSeconds, c.durationSeconds as durationSeconds,
0L as unknownDurationSeconds, 0L as cardAbsentDurationSeconds,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId, c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId, c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
c.previousRegistrationKey as previousRegistrationKey, c.previousRegistrationKey as previousRegistrationKey,
@ -604,65 +600,14 @@ select
c.startedAtEpochSecond as startedAtEpochSecond, c.startedAtEpochSecond as startedAtEpochSecond,
c.endedAtEpochSecond as endedAtEpochSecond, c.endedAtEpochSecond as endedAtEpochSecond,
c.durationSeconds as durationSeconds, c.durationSeconds as durationSeconds,
sum( c.cardAbsentDurationSeconds as cardAbsentDurationSeconds,
case
when v.startedAtEpochSecond <= c.startedAtEpochSecond and (v.endedAtEpochSecond is null or v.endedAtEpochSecond >= c.endedAtEpochSecond)
then c.durationSeconds
when v.startedAtEpochSecond <= c.startedAtEpochSecond
then v.endedAtEpochSecond - c.startedAtEpochSecond
when v.endedAtEpochSecond is null or v.endedAtEpochSecond >= c.endedAtEpochSecond
then c.endedAtEpochSecond - v.startedAtEpochSecond
else v.endedAtEpochSecond - v.startedAtEpochSecond
end
) as cardPresentDurationSeconds,
c.unknownDurationSeconds as unknownDurationSeconds,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId, c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId, c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
c.previousRegistrationKey as previousRegistrationKey, c.previousRegistrationKey as previousRegistrationKey,
c.nextRegistrationKey as nextRegistrationKey, c.nextRegistrationKey as nextRegistrationKey,
c.previousVehicleKey as previousVehicleKey, c.previousVehicleKey as previousVehicleKey,
c.nextVehicleKey as nextVehicleKey c.nextVehicleKey as nextVehicleKey
from DailyWeeklyRestCandidateCoverageUnknownResolvedInterval as c unidirectional, from DailyWeeklyRestCandidateCoverageUnknownResolvedInterval as c;
TachographVehicleUsageIntervalInputEvent#keepall as v
where v.driverKey = c.driverKey
and v.startedAtEpochSecond < c.endedAtEpochSecond
and (v.endedAtEpochSecond is null or v.endedAtEpochSecond > c.startedAtEpochSecond)
group by
c.sessionId,
c.driverKey,
c.startedAtEpochSecond,
c.endedAtEpochSecond,
c.durationSeconds,
c.unknownDurationSeconds,
c.previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId,
c.previousRegistrationKey,
c.nextRegistrationKey,
c.previousVehicleKey,
c.nextVehicleKey;
insert into DailyWeeklyRestCandidateCoverageCardResolvedInterval
select
c.sessionId as sessionId,
c.driverKey as driverKey,
c.startedAtEpochSecond as startedAtEpochSecond,
c.endedAtEpochSecond as endedAtEpochSecond,
c.durationSeconds as durationSeconds,
0L as cardPresentDurationSeconds,
c.unknownDurationSeconds as unknownDurationSeconds,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
c.previousRegistrationKey as previousRegistrationKey,
c.nextRegistrationKey as nextRegistrationKey,
c.previousVehicleKey as previousVehicleKey,
c.nextVehicleKey as nextVehicleKey
from DailyWeeklyRestCandidateCoverageUnknownResolvedInterval as c
where not exists (
select * from TachographVehicleUsageIntervalInputEvent#keepall as v
where v.driverKey = c.driverKey
and v.startedAtEpochSecond < c.endedAtEpochSecond
and (v.endedAtEpochSecond is null or v.endedAtEpochSecond > c.startedAtEpochSecond)
);
insert into DailyWeeklyRestCandidateCoverageCardResolvedIntervalWindow insert into DailyWeeklyRestCandidateCoverageCardResolvedIntervalWindow
select * select *
@ -1313,10 +1258,8 @@ select
c.startedAtEpochSecond as startedAtEpochSecond, c.startedAtEpochSecond as startedAtEpochSecond,
c.endedAtEpochSecond as endedAtEpochSecond, c.endedAtEpochSecond as endedAtEpochSecond,
c.durationSeconds as durationSeconds, c.durationSeconds as durationSeconds,
c.cardPresentDurationSeconds as cardPresentDurationSeconds, c.cardAbsentDurationSeconds as cardAbsentDurationSeconds,
(c.cardPresentDurationSeconds * 100.0d) / c.durationSeconds as cardPresentCoveragePercent, (c.cardAbsentDurationSeconds * 100.0d) / c.durationSeconds as cardAbsentCoveragePercent,
c.unknownDurationSeconds as unknownDurationSeconds,
(c.unknownDurationSeconds * 100.0d) / c.durationSeconds as unknownCoveragePercent,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId, c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId, c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
c.previousRegistrationKey as previousRegistrationKey, c.previousRegistrationKey as previousRegistrationKey,
@ -1517,7 +1460,20 @@ from DailyWeeklyRestCandidateCoverageFinalizationRequest as request unidirection
DailyWeeklyRestCandidateCoverageCardResolvedIntervalWindow as c DailyWeeklyRestCandidateCoverageCardResolvedIntervalWindow as c
where c.driverKey = request.driverKey where c.driverKey = request.driverKey
and c.startedAtEpochSecond = request.startedAtEpochSecond and c.startedAtEpochSecond = request.startedAtEpochSecond
and c.endedAtEpochSecond = request.endedAtEpochSecond; and c.endedAtEpochSecond = request.endedAtEpochSecond
and not exists (
select * from DailyWeeklyRestCandidateCoverageEmittedKeyWindow as emitted
where emitted.driverKey = request.driverKey
and emitted.startedAtEpochSecond = request.startedAtEpochSecond
and emitted.endedAtEpochSecond = request.endedAtEpochSecond
);
insert into DailyWeeklyRestCandidateCoverageEmittedKeyWindow
select
driverKey,
startedAtEpochSecond,
endedAtEpochSecond
from DailyWeeklyRestCandidateCoverageInterval;
context PerDriver context PerDriver
create window PreviousVehicleUsageInterval#lastevent as TachographVehicleUsageIntervalInputEvent; create window PreviousVehicleUsageInterval#lastevent as TachographVehicleUsageIntervalInputEvent;
@ -1561,10 +1517,8 @@ select
c.startedAtEpochSecond as startedAtEpochSecond, c.startedAtEpochSecond as startedAtEpochSecond,
c.endedAtEpochSecond as endedAtEpochSecond, c.endedAtEpochSecond as endedAtEpochSecond,
c.durationSeconds as durationSeconds, c.durationSeconds as durationSeconds,
c.cardPresentDurationSeconds as cardPresentDurationSeconds, c.cardAbsentDurationSeconds as cardAbsentDurationSeconds,
c.cardPresentCoveragePercent as cardPresentCoveragePercent, c.cardAbsentCoveragePercent as cardAbsentCoveragePercent,
c.unknownDurationSeconds as unknownDurationSeconds,
c.unknownCoveragePercent as unknownCoveragePercent,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId, c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId, c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
c.previousRegistrationKey as previousRegistrationKey, c.previousRegistrationKey as previousRegistrationKey,
@ -1593,7 +1547,7 @@ from DailyWeeklyRestCandidateCoverageInterval as c
where c.previousRegistrationKey is not null where c.previousRegistrationKey is not null
and c.nextRegistrationKey is not null and c.nextRegistrationKey is not null
and c.previousRegistrationKey != c.nextRegistrationKey and c.previousRegistrationKey != c.nextRegistrationKey
and c.unknownDurationSeconds * 100L >= c.durationSeconds * 95L; and c.cardAbsentDurationSeconds * 100L >= c.durationSeconds * 95L;
insert into PotentialInVehicleOvernightStayInterval insert into PotentialInVehicleOvernightStayInterval
select select
@ -1602,10 +1556,8 @@ select
c.startedAtEpochSecond as startedAtEpochSecond, c.startedAtEpochSecond as startedAtEpochSecond,
c.endedAtEpochSecond as endedAtEpochSecond, c.endedAtEpochSecond as endedAtEpochSecond,
c.durationSeconds as durationSeconds, c.durationSeconds as durationSeconds,
c.cardPresentDurationSeconds as cardPresentDurationSeconds, c.cardAbsentDurationSeconds as cardAbsentDurationSeconds,
c.cardPresentCoveragePercent as cardPresentCoveragePercent, c.cardAbsentCoveragePercent as cardAbsentCoveragePercent,
c.unknownDurationSeconds as unknownDurationSeconds,
c.unknownCoveragePercent as unknownCoveragePercent,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId, c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId, c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
c.previousRegistrationKey as previousRegistrationKey, c.previousRegistrationKey as previousRegistrationKey,
@ -1634,7 +1586,7 @@ from DailyWeeklyRestCandidateCoverageInterval as c
where c.previousRegistrationKey is not null where c.previousRegistrationKey is not null
and c.nextRegistrationKey is not null and c.nextRegistrationKey is not null
and c.previousRegistrationKey = c.nextRegistrationKey and c.previousRegistrationKey = c.nextRegistrationKey
and c.cardPresentDurationSeconds >= c.durationSeconds; and c.cardAbsentDurationSeconds = 0L;
insert into UnclassifiedDailyWeeklyRestCandidateCoverageInterval insert into UnclassifiedDailyWeeklyRestCandidateCoverageInterval
select select
@ -1643,10 +1595,8 @@ select
c.startedAtEpochSecond as startedAtEpochSecond, c.startedAtEpochSecond as startedAtEpochSecond,
c.endedAtEpochSecond as endedAtEpochSecond, c.endedAtEpochSecond as endedAtEpochSecond,
c.durationSeconds as durationSeconds, c.durationSeconds as durationSeconds,
c.cardPresentDurationSeconds as cardPresentDurationSeconds, c.cardAbsentDurationSeconds as cardAbsentDurationSeconds,
c.cardPresentCoveragePercent as cardPresentCoveragePercent, c.cardAbsentCoveragePercent as cardAbsentCoveragePercent,
c.unknownDurationSeconds as unknownDurationSeconds,
c.unknownCoveragePercent as unknownCoveragePercent,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId, c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId, c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
c.previousRegistrationKey as previousRegistrationKey, c.previousRegistrationKey as previousRegistrationKey,
@ -1676,13 +1626,13 @@ where not (
c.previousRegistrationKey is not null c.previousRegistrationKey is not null
and c.nextRegistrationKey is not null and c.nextRegistrationKey is not null
and c.previousRegistrationKey != c.nextRegistrationKey and c.previousRegistrationKey != c.nextRegistrationKey
and c.unknownDurationSeconds * 100L >= c.durationSeconds * 95L and c.cardAbsentDurationSeconds * 100L >= c.durationSeconds * 95L
) )
and not ( and not (
c.previousRegistrationKey is not null c.previousRegistrationKey is not null
and c.nextRegistrationKey is not null and c.nextRegistrationKey is not null
and c.previousRegistrationKey = c.nextRegistrationKey and c.previousRegistrationKey = c.nextRegistrationKey
and c.cardPresentDurationSeconds >= c.durationSeconds and c.cardAbsentDurationSeconds = 0L
); );
@Priority(40) @Priority(40)
@ -1698,8 +1648,7 @@ select
s.vehicleKey as vehicleKey, s.vehicleKey as vehicleKey,
s.containedPotentialInVehicleOvernightStayIntervalCount as containedPotentialInVehicleOvernightStayIntervalCount, s.containedPotentialInVehicleOvernightStayIntervalCount as containedPotentialInVehicleOvernightStayIntervalCount,
s.containedPotentialInVehicleOvernightStayDurationSeconds as containedPotentialInVehicleOvernightStayDurationSeconds, s.containedPotentialInVehicleOvernightStayDurationSeconds as containedPotentialInVehicleOvernightStayDurationSeconds,
s.containedCardPresentDurationSeconds as containedCardPresentDurationSeconds, s.containedCardAbsentDurationSeconds as containedCardAbsentDurationSeconds,
s.containedUnknownDurationSeconds as containedUnknownDurationSeconds,
s.firstPotentialInVehicleOvernightStayStartedAtEpochSecond as firstPotentialInVehicleOvernightStayStartedAtEpochSecond, s.firstPotentialInVehicleOvernightStayStartedAtEpochSecond as firstPotentialInVehicleOvernightStayStartedAtEpochSecond,
s.lastPotentialInVehicleOvernightStayEndedAtEpochSecond as lastPotentialInVehicleOvernightStayEndedAtEpochSecond, s.lastPotentialInVehicleOvernightStayEndedAtEpochSecond as lastPotentialInVehicleOvernightStayEndedAtEpochSecond,
s.firstPreviousDrivingSourceIntervalId as firstPreviousDrivingSourceIntervalId, s.firstPreviousDrivingSourceIntervalId as firstPreviousDrivingSourceIntervalId,
@ -1712,7 +1661,7 @@ where s.driverKey = c.driverKey
c.previousRegistrationKey is not null c.previousRegistrationKey is not null
and c.nextRegistrationKey is not null and c.nextRegistrationKey is not null
and c.previousRegistrationKey = c.nextRegistrationKey and c.previousRegistrationKey = c.nextRegistrationKey
and c.cardPresentDurationSeconds >= c.durationSeconds and c.cardAbsentDurationSeconds = 0L
) )
or s.registrationKey != c.previousRegistrationKey or s.registrationKey != c.previousRegistrationKey
or ( or (
@ -1730,7 +1679,7 @@ where s.driverKey = c.driverKey
c.previousRegistrationKey is not null c.previousRegistrationKey is not null
and c.nextRegistrationKey is not null and c.nextRegistrationKey is not null
and c.previousRegistrationKey = c.nextRegistrationKey and c.previousRegistrationKey = c.nextRegistrationKey
and c.cardPresentDurationSeconds >= c.durationSeconds and c.cardAbsentDurationSeconds = 0L
); );
@Priority(30) @Priority(30)
@ -1744,8 +1693,7 @@ select
s.vehicleKey as vehicleKey, s.vehicleKey as vehicleKey,
s.containedPotentialInVehicleOvernightStayIntervalCount + 1 as containedPotentialInVehicleOvernightStayIntervalCount, s.containedPotentialInVehicleOvernightStayIntervalCount + 1 as containedPotentialInVehicleOvernightStayIntervalCount,
s.containedPotentialInVehicleOvernightStayDurationSeconds + c.durationSeconds as containedPotentialInVehicleOvernightStayDurationSeconds, s.containedPotentialInVehicleOvernightStayDurationSeconds + c.durationSeconds as containedPotentialInVehicleOvernightStayDurationSeconds,
s.containedCardPresentDurationSeconds + c.cardPresentDurationSeconds as containedCardPresentDurationSeconds, s.containedCardAbsentDurationSeconds + c.cardAbsentDurationSeconds as containedCardAbsentDurationSeconds,
s.containedUnknownDurationSeconds + c.unknownDurationSeconds as containedUnknownDurationSeconds,
s.firstPotentialInVehicleOvernightStayStartedAtEpochSecond as firstPotentialInVehicleOvernightStayStartedAtEpochSecond, s.firstPotentialInVehicleOvernightStayStartedAtEpochSecond as firstPotentialInVehicleOvernightStayStartedAtEpochSecond,
c.endedAtEpochSecond as lastPotentialInVehicleOvernightStayEndedAtEpochSecond, c.endedAtEpochSecond as lastPotentialInVehicleOvernightStayEndedAtEpochSecond,
s.firstPreviousDrivingSourceIntervalId as firstPreviousDrivingSourceIntervalId, s.firstPreviousDrivingSourceIntervalId as firstPreviousDrivingSourceIntervalId,
@ -1755,7 +1703,7 @@ where s.driverKey = c.driverKey
and c.previousRegistrationKey is not null and c.previousRegistrationKey is not null
and c.nextRegistrationKey is not null and c.nextRegistrationKey is not null
and c.previousRegistrationKey = c.nextRegistrationKey and c.previousRegistrationKey = c.nextRegistrationKey
and c.cardPresentDurationSeconds >= c.durationSeconds and c.cardAbsentDurationSeconds = 0L
and s.registrationKey = c.previousRegistrationKey and s.registrationKey = c.previousRegistrationKey
and ( and (
s.vehicleKey is null s.vehicleKey is null
@ -1777,8 +1725,7 @@ select
end as vehicleKey, end as vehicleKey,
1 as containedPotentialInVehicleOvernightStayIntervalCount, 1 as containedPotentialInVehicleOvernightStayIntervalCount,
c.durationSeconds as containedPotentialInVehicleOvernightStayDurationSeconds, c.durationSeconds as containedPotentialInVehicleOvernightStayDurationSeconds,
c.cardPresentDurationSeconds as containedCardPresentDurationSeconds, c.cardAbsentDurationSeconds as containedCardAbsentDurationSeconds,
c.unknownDurationSeconds as containedUnknownDurationSeconds,
c.startedAtEpochSecond as firstPotentialInVehicleOvernightStayStartedAtEpochSecond, c.startedAtEpochSecond as firstPotentialInVehicleOvernightStayStartedAtEpochSecond,
c.endedAtEpochSecond as lastPotentialInVehicleOvernightStayEndedAtEpochSecond, c.endedAtEpochSecond as lastPotentialInVehicleOvernightStayEndedAtEpochSecond,
c.previousDrivingSourceIntervalId as firstPreviousDrivingSourceIntervalId, c.previousDrivingSourceIntervalId as firstPreviousDrivingSourceIntervalId,
@ -1788,7 +1735,7 @@ where priorCoverage.driverKey = c.driverKey
and c.previousRegistrationKey is not null and c.previousRegistrationKey is not null
and c.nextRegistrationKey is not null and c.nextRegistrationKey is not null
and c.previousRegistrationKey = c.nextRegistrationKey and c.previousRegistrationKey = c.nextRegistrationKey
and c.cardPresentDurationSeconds >= c.durationSeconds and c.cardAbsentDurationSeconds = 0L
and not exists ( and not exists (
select * from OpenPotentialInVehicleTripState as s select * from OpenPotentialInVehicleTripState as s
where s.driverKey = c.driverKey where s.driverKey = c.driverKey

View File

@ -15,7 +15,7 @@ select
then c.endedAtEpochSecond - u.startedAtEpochSecond then c.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond else u.endedAtEpochSecond - u.startedAtEpochSecond
end end
) as unknownDurationSeconds, ) as cardAbsentDurationSeconds,
(sum( (sum(
case case
when u.startedAtEpochSecond <= c.startedAtEpochSecond and u.endedAtEpochSecond >= c.endedAtEpochSecond when u.startedAtEpochSecond <= c.startedAtEpochSecond and u.endedAtEpochSecond >= c.endedAtEpochSecond
@ -26,7 +26,7 @@ select
then c.endedAtEpochSecond - u.startedAtEpochSecond then c.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond else u.endedAtEpochSecond - u.startedAtEpochSecond
end end
) * 100.0d) / c.durationSeconds as unknownCoveragePercent, ) * 100.0d) / c.durationSeconds as cardAbsentCoveragePercent,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId, c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId, c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
c.previousRegistrationKey as previousRegistrationKey, c.previousRegistrationKey as previousRegistrationKey,

View File

@ -0,0 +1,154 @@
package at.procon.eventhub.tachographfilesession.api;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import at.procon.eventhub.dto.DriverCardRefDto;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographCompositeSessionResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeDriverEventsResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeSessionListDriversResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.service.DriverNotFoundInCompositeSessionException;
import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionService;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class TachographCompositeSessionControllerTest {
@Test
void createsLoadsListsAndProcessesCompositeSession() throws Exception {
TachographCompositeSessionService service = org.mockito.Mockito.mock(TachographCompositeSessionService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TachographCompositeSessionController(service))
.setControllerAdvice(new TachographFileSessionExceptionHandler())
.build();
UUID compositeSessionId = UUID.randomUUID();
UUID firstSessionId = UUID.randomUUID();
UUID secondSessionId = UUID.randomUUID();
TachographFileDriverSummaryDto driver = new TachographFileDriverSummaryDto("12:123", "Muster", "Max", "12", "CARD0000000001", 5, 3);
TachographCompositeSessionSummaryDto summary = new TachographCompositeSessionSummaryDto(
compositeSessionId,
"default",
"fleet-week-1",
List.of(firstSessionId, secondSessionId),
List.of(driver),
Instant.parse("2026-05-22T10:00:00Z")
);
when(service.createCompositeSession(org.mockito.ArgumentMatchers.any()))
.thenReturn(new CreateTachographCompositeSessionResponse(summary));
when(service.getCompositeSession(compositeSessionId)).thenReturn(summary);
when(service.listDrivers(compositeSessionId))
.thenReturn(new TachographCompositeSessionListDriversResponse(compositeSessionId, List.of(driver)));
when(service.getMergedDriverEvents(compositeSessionId, "12:123"))
.thenReturn(new TachographCompositeDriverEventsResponse(
compositeSessionId,
"12:123",
List.of(firstSessionId, secondSessionId),
1,
List.of(event("EVT-1", OffsetDateTime.parse("2026-05-22T08:00:00Z"), EventType.WORK))
));
when(service.getMergedDriverTimeline(compositeSessionId, "12:123"))
.thenReturn(new ResolvedDriverTimeline(
"COMPOSITE_TACHOGRAPH_FILE_SESSION",
OffsetDateTime.parse("2026-05-22T08:00:00Z"),
OffsetDateTime.parse("2026-05-22T10:00:00Z"),
List.of(),
List.of(),
List.of(),
List.of()
));
mockMvc.perform(post("/api/eventhub/tachograph-composite-sessions")
.contentType("application/json")
.content("""
{
"sessionIds": ["%s", "%s"],
"label": "fleet-week-1"
}
""".formatted(firstSessionId, secondSessionId)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.session.compositeSessionId").value(compositeSessionId.toString()))
.andExpect(jsonPath("$.session.memberSessionIds[0]").value(firstSessionId.toString()));
mockMvc.perform(get("/api/eventhub/tachograph-composite-sessions/{compositeSessionId}", compositeSessionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.compositeSessionId").value(compositeSessionId.toString()))
.andExpect(jsonPath("$.drivers[0].driverKey").value("12:123"));
mockMvc.perform(get("/api/eventhub/tachograph-composite-sessions/{compositeSessionId}/drivers", compositeSessionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.drivers[0].cardVehicleUsageIntervalCount").value(3));
mockMvc.perform(get("/api/eventhub/tachograph-composite-sessions/{compositeSessionId}/drivers/{driverKey}/events",
compositeSessionId, "12:123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.eventCount").value(1))
.andExpect(jsonPath("$.events[0].externalSourceEventId").value("EVT-1"))
.andExpect(jsonPath("$.events[0].occurredAt").value("2026-05-22T08:00:00Z"));
mockMvc.perform(get("/api/eventhub/tachograph-composite-sessions/{compositeSessionId}/drivers/{driverKey}/timeline",
compositeSessionId, "12:123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.sourceKind").value("COMPOSITE_TACHOGRAPH_FILE_SESSION"))
.andExpect(jsonPath("$.loadedFrom").value("2026-05-22T08:00:00Z"));
}
@Test
void returnsNotFoundWhenDriverIsMissingInCompositeSession() throws Exception {
TachographCompositeSessionService service = org.mockito.Mockito.mock(TachographCompositeSessionService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TachographCompositeSessionController(service))
.setControllerAdvice(new TachographFileSessionExceptionHandler())
.build();
UUID compositeSessionId = UUID.randomUUID();
when(service.getMergedDriverEvents(eq(compositeSessionId), eq("missing")))
.thenThrow(new DriverNotFoundInCompositeSessionException(compositeSessionId, "missing"));
mockMvc.perform(get("/api/eventhub/tachograph-composite-sessions/{compositeSessionId}/drivers/{driverKey}/events",
compositeSessionId, "missing"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value(
"Driver 'missing' was not found in tachograph composite session '%s'.".formatted(compositeSessionId)
));
}
private static EventHubEventDto event(String externalSourceEventId, OffsetDateTime occurredAt, EventType eventType) {
return new EventHubEventDto(
null,
externalSourceEventId,
new DriverRefDto(null, new DriverCardRefDto("12", 12, "CARD0000000001")),
new VehicleRefDto(null, "VIN00000000000001", null, new VehicleRegistrationRefDto("12", 12, "W-12345A")),
occurredAt,
null,
null,
EventDomain.DRIVER_ACTIVITY,
eventType,
EventLifecycle.START,
null,
null,
null,
null,
null,
false,
null
);
}
}

View File

@ -178,8 +178,6 @@ class TachographFileSessionControllerTest {
OffsetDateTime.parse("2026-05-12T10:00:00Z"), OffsetDateTime.parse("2026-05-12T10:00:00Z"),
OffsetDateTime.parse("2026-05-12T22:00:00Z"), OffsetDateTime.parse("2026-05-12T22:00:00Z"),
43_200L, 43_200L,
0L,
0.0d,
43_200L, 43_200L,
100.0d, 100.0d,
"ACT-2", "ACT-2",
@ -216,8 +214,6 @@ class TachographFileSessionControllerTest {
OffsetDateTime.parse("2026-05-12T10:00:00Z"), OffsetDateTime.parse("2026-05-12T10:00:00Z"),
OffsetDateTime.parse("2026-05-12T22:00:00Z"), OffsetDateTime.parse("2026-05-12T22:00:00Z"),
43_200L, 43_200L,
0L,
0.0d,
43_200L, 43_200L,
100.0d, 100.0d,
"ACT-2", "ACT-2",

View File

@ -563,8 +563,8 @@ class DriverTimelineBuilderTest {
assertThat(intervals.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z")); assertThat(intervals.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z"));
assertThat(intervals.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-02T00:00:00Z")); assertThat(intervals.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-02T00:00:00Z"));
assertThat(intervals.get(0).durationSeconds()).isEqualTo(50_400L); assertThat(intervals.get(0).durationSeconds()).isEqualTo(50_400L);
assertThat(intervals.get(0).unknownDurationSeconds()).isEqualTo(50_399L); assertThat(intervals.get(0).cardAbsentDurationSeconds()).isEqualTo(50_399L);
assertThat(intervals.get(0).unknownCoveragePercent()).isGreaterThan(99.9d); assertThat(intervals.get(0).cardAbsentCoveragePercent()).isGreaterThan(99.9d);
assertThat(intervals.get(0).previousDrivingSourceIntervalId()).isEqualTo("ACT-1"); assertThat(intervals.get(0).previousDrivingSourceIntervalId()).isEqualTo("ACT-1");
assertThat(intervals.get(0).nextDrivingSourceIntervalId()).isEqualTo("ACT-2"); assertThat(intervals.get(0).nextDrivingSourceIntervalId()).isEqualTo("ACT-2");
} }

View File

@ -128,8 +128,7 @@ class DriverTimelineReusableProjectionBuilderTest {
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1); assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1);
TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent coverageInterval = TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent coverageInterval =
reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0); reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0);
assertThat(coverageInterval.unknownCoveragePercent()).isCloseTo(99.998d, org.assertj.core.data.Offset.offset(0.001d)); assertThat(coverageInterval.cardAbsentCoveragePercent()).isCloseTo(99.998d, org.assertj.core.data.Offset.offset(0.001d));
assertThat(coverageInterval.cardPresentCoveragePercent()).isEqualTo(0.0d);
assertThat(reusableBundle.drivingInterruptionVehicleChangeIntervals()).containsExactlyElementsOf(legacyDrivingInterruptionVehicleChanges); assertThat(reusableBundle.drivingInterruptionVehicleChangeIntervals()).containsExactlyElementsOf(legacyDrivingInterruptionVehicleChanges);
assertThat(reusableBundle.vuCardAbsentIntervals()).containsExactlyElementsOf(legacyVuCardAbsentIntervals); assertThat(reusableBundle.vuCardAbsentIntervals()).containsExactlyElementsOf(legacyVuCardAbsentIntervals);
assertThat(reusableBundle.potentialHomeOvernightStayIntervals()).hasSize(1); assertThat(reusableBundle.potentialHomeOvernightStayIntervals()).hasSize(1);
@ -137,8 +136,8 @@ class DriverTimelineReusableProjectionBuilderTest {
.isEqualTo(legacyPotentialHomeOvernightStays.get(0).startedAt()); .isEqualTo(legacyPotentialHomeOvernightStays.get(0).startedAt());
assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).endedAt()) assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).endedAt())
.isEqualTo(legacyPotentialHomeOvernightStays.get(0).endedAt()); .isEqualTo(legacyPotentialHomeOvernightStays.get(0).endedAt());
assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).unknownCoveragePercent()) assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).cardAbsentCoveragePercent())
.isEqualTo(legacyPotentialHomeOvernightStays.get(0).unknownCoveragePercent()); .isEqualTo(legacyPotentialHomeOvernightStays.get(0).cardAbsentCoveragePercent());
assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).beginBoundaryOdometerKm()) assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).beginBoundaryOdometerKm())
.isEqualTo(200L); .isEqualTo(200L);
assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).endBoundaryOdometerKm()) assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).endBoundaryOdometerKm())
@ -266,9 +265,7 @@ class DriverTimelineReusableProjectionBuilderTest {
assertThat(reusableBundle.drivingInterruptionVehicleChangeIntervals()).isEmpty(); assertThat(reusableBundle.drivingInterruptionVehicleChangeIntervals()).isEmpty();
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1); assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1);
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentCoveragePercent()) assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentCoveragePercent())
.isEqualTo(100.0d);
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0).unknownCoveragePercent())
.isEqualTo(0.0d); .isEqualTo(0.0d);
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0).beginGeoEventId()) assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0).beginGeoEventId())
.isEqualTo("SUP-1"); .isEqualTo("SUP-1");
@ -296,8 +293,6 @@ class DriverTimelineReusableProjectionBuilderTest {
.isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z")); .isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z"));
assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).endedAt()) assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).endedAt())
.isEqualTo(OffsetDateTime.parse("2026-05-02T00:00:00Z")); .isEqualTo(OffsetDateTime.parse("2026-05-02T00:00:00Z"));
assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).cardPresentCoveragePercent())
.isEqualTo(100.0d);
assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).beginGeoEventId()) assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).beginGeoEventId())
.isEqualTo("SUP-1"); .isEqualTo("SUP-1");
assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).beginGeoEvent()) assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).beginGeoEvent())

View File

@ -0,0 +1,306 @@
package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import at.procon.eventhub.dto.DriverCardRefDto;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import at.procon.eventhub.processing.service.UnifiedEventTimelineReconstructor;
import at.procon.eventhub.service.EventAcquisitionRecordKeyService;
import at.procon.eventhub.service.EventHubEventSorter;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographCompositeSessionRequest;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographCompositeSessionResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeDriverEventsResponse;
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriver;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class TachographCompositeSessionServiceTest {
@Test
void createsCompositeSessionAndAggregatesDriversAcrossMemberSessions() {
TachographCompositeSessionRepository compositeRepository = new InMemoryTachographCompositeSessionRepository();
TachographFileSessionRepository fileSessionRepository = Mockito.mock(TachographFileSessionRepository.class);
DriverTimelineEventBuilder eventBuilder = Mockito.mock(DriverTimelineEventBuilder.class);
UnifiedEventTimelineReconstructor reconstructor = Mockito.mock(UnifiedEventTimelineReconstructor.class);
TachographCompositeSessionService service = new TachographCompositeSessionService(
compositeRepository,
fileSessionRepository,
eventBuilder,
reconstructor,
new EventAcquisitionRecordKeyService(),
new EventHubEventSorter()
);
UUID firstSessionId = UUID.randomUUID();
UUID secondSessionId = UUID.randomUUID();
TachographFileSession first = session(
firstSessionId,
driver("12:123", "Muster", "Max", "12", "CARD0000000001", 2, 1, List.of(new ExtractionWarning("A", "a", "a"))),
driver("13:456", "Doe", "Jane", "13", "CARD0000000002", 1, 1, List.of())
);
TachographFileSession second = session(
secondSessionId,
driver("12:123", "Muster", "Max", "12", "CARD0000000001", 3, 2, List.of()),
driver("14:789", "Smith", "John", "14", "CARD0000000003", 4, 5, List.of())
);
when(fileSessionRepository.find(firstSessionId)).thenReturn(java.util.Optional.of(first));
when(fileSessionRepository.find(secondSessionId)).thenReturn(java.util.Optional.of(second));
CreateTachographCompositeSessionResponse response = service.createCompositeSession(
new CreateTachographCompositeSessionRequest(List.of(firstSessionId, secondSessionId, firstSessionId), "fleet-week-1")
);
assertThat(response.session().tenantKey()).isEqualTo("default");
assertThat(response.session().label()).isEqualTo("fleet-week-1");
assertThat(response.session().memberSessionIds()).containsExactly(firstSessionId, secondSessionId);
assertThat(response.session().drivers()).hasSize(3);
assertThat(response.session().drivers())
.filteredOn(driver -> driver.driverKey().equals("12:123"))
.singleElement()
.satisfies(driverSummary -> {
assertThat(driverSummary.activityIntervalCount()).isEqualTo(5);
assertThat(driverSummary.cardVehicleUsageIntervalCount()).isEqualTo(3);
assertThat(driverSummary.surname()).isEqualTo("Muster");
assertThat(driverSummary.firstNames()).isEqualTo("Max");
assertThat(driverSummary.cardNumber()).isEqualTo("CARD0000000001");
});
}
@Test
void mergesDriverEventsAcrossSessionsAndDeduplicatesBySemanticSignature() {
TachographCompositeSessionRepository compositeRepository = new InMemoryTachographCompositeSessionRepository();
TachographFileSessionRepository fileSessionRepository = Mockito.mock(TachographFileSessionRepository.class);
DriverTimelineEventBuilder eventBuilder = Mockito.mock(DriverTimelineEventBuilder.class);
UnifiedEventTimelineReconstructor reconstructor = Mockito.mock(UnifiedEventTimelineReconstructor.class);
TachographCompositeSessionService service = new TachographCompositeSessionService(
compositeRepository,
fileSessionRepository,
eventBuilder,
reconstructor,
new EventAcquisitionRecordKeyService(),
new EventHubEventSorter()
);
UUID firstSessionId = UUID.randomUUID();
UUID secondSessionId = UUID.randomUUID();
TachographFileSession first = session(firstSessionId, driver("12:123", "Muster", "Max", "12", "CARD0000000001", 0, 0, List.of()));
TachographFileSession second = session(secondSessionId, driver("12:123", "Muster", "Max", "12", "CARD0000000001", 0, 0, List.of()));
when(fileSessionRepository.find(firstSessionId)).thenReturn(java.util.Optional.of(first));
when(fileSessionRepository.find(secondSessionId)).thenReturn(java.util.Optional.of(second));
CreateTachographCompositeSessionResponse created = service.createCompositeSession(
new CreateTachographCompositeSessionRequest(List.of(firstSessionId, secondSessionId), "driver-merge")
);
DriverExtractionSession firstDriver = first.driversByKey().get("12:123");
DriverExtractionSession secondDriver = second.driversByKey().get("12:123");
OffsetDateTime firstTimestamp = OffsetDateTime.parse("2026-05-22T08:00:00Z");
OffsetDateTime secondTimestamp = OffsetDateTime.parse("2026-05-22T09:00:00Z");
when(eventBuilder.buildEvents(first, firstDriver)).thenReturn(List.of(
event("duplicate-a", firstTimestamp, EventType.WORK),
event("unique-a", secondTimestamp, EventType.DRIVE)
));
when(eventBuilder.buildEvents(second, secondDriver)).thenReturn(List.of(
event("duplicate-b", firstTimestamp, EventType.WORK),
event("unique-b", secondTimestamp.plusMinutes(30), EventType.BREAK_REST)
));
TachographCompositeDriverEventsResponse response =
service.getMergedDriverEvents(created.session().compositeSessionId(), "12:123");
assertThat(response.sourceSessionIds()).containsExactly(firstSessionId, secondSessionId);
assertThat(response.eventCount()).isEqualTo(3);
assertThat(response.events()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("duplicate-a", "unique-a", "unique-b");
}
@Test
void reconstructsMergedDriverTimelineFromMergedEventsAndWarnings() {
TachographCompositeSessionRepository compositeRepository = new InMemoryTachographCompositeSessionRepository();
TachographFileSessionRepository fileSessionRepository = Mockito.mock(TachographFileSessionRepository.class);
DriverTimelineEventBuilder eventBuilder = Mockito.mock(DriverTimelineEventBuilder.class);
UnifiedEventTimelineReconstructor reconstructor = Mockito.mock(UnifiedEventTimelineReconstructor.class);
TachographCompositeSessionService service = new TachographCompositeSessionService(
compositeRepository,
fileSessionRepository,
eventBuilder,
reconstructor,
new EventAcquisitionRecordKeyService(),
new EventHubEventSorter()
);
UUID firstSessionId = UUID.randomUUID();
UUID secondSessionId = UUID.randomUUID();
DriverExtractionSession firstDriver = driver("12:123", "Muster", "Max", "12", "CARD0000000001", 0, 0, List.of(
new ExtractionWarning("CARD_GAP", "gap", "p1")
));
DriverExtractionSession secondDriver = driver("12:123", "Muster", "Max", "12", "CARD0000000001", 0, 0, List.of(
new ExtractionWarning("DUPLICATE", "duplicate", "p2")
));
TachographFileSession first = session(firstSessionId, List.of(new ExtractionWarning("SESSION_A", "warn-a", "s1")), firstDriver);
TachographFileSession second = session(secondSessionId, List.of(new ExtractionWarning("SESSION_B", "warn-b", "s2")), secondDriver);
when(fileSessionRepository.find(firstSessionId)).thenReturn(java.util.Optional.of(first));
when(fileSessionRepository.find(secondSessionId)).thenReturn(java.util.Optional.of(second));
CreateTachographCompositeSessionResponse created = service.createCompositeSession(
new CreateTachographCompositeSessionRequest(List.of(firstSessionId, secondSessionId), null)
);
OffsetDateTime occurredAt = OffsetDateTime.parse("2026-05-22T08:00:00Z");
when(eventBuilder.buildEvents(first, firstDriver)).thenReturn(List.of(event("a", occurredAt, EventType.WORK)));
when(eventBuilder.buildEvents(second, secondDriver)).thenReturn(List.of(event("b", occurredAt.plusHours(1), EventType.DRIVE)));
ResolvedDriverTimeline expected = new ResolvedDriverTimeline(
"COMPOSITE_TACHOGRAPH_FILE_SESSION",
occurredAt,
occurredAt.plusHours(1),
List.of(),
List.of(),
List.of(),
List.of()
);
when(reconstructor.reconstruct(any(), eq("12:123"), any(), any(), eq("COMPOSITE_TACHOGRAPH_FILE_SESSION")))
.thenReturn(expected);
ResolvedDriverTimeline actual =
service.getMergedDriverTimeline(created.session().compositeSessionId(), "12:123");
assertThat(actual).isSameAs(expected);
ArgumentCaptor<List<EventHubEventDto>> eventsCaptor = ArgumentCaptor.forClass(List.class);
ArgumentCaptor<List<ExtractionWarning>> warningsCaptor = ArgumentCaptor.forClass(List.class);
verify(reconstructor).reconstruct(
eq(created.session().compositeSessionId()),
eq("12:123"),
eventsCaptor.capture(),
warningsCaptor.capture(),
eq("COMPOSITE_TACHOGRAPH_FILE_SESSION")
);
assertThat(eventsCaptor.getValue()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("a", "b");
assertThat(warningsCaptor.getValue()).containsExactly(
new ExtractionWarning("SESSION_A", "warn-a", "s1"),
new ExtractionWarning("CARD_GAP", "gap", "p1"),
new ExtractionWarning("SESSION_B", "warn-b", "s2"),
new ExtractionWarning("DUPLICATE", "duplicate", "p2")
);
}
@Test
void rejectsMissingDriverInCompositeSession() {
TachographCompositeSessionRepository compositeRepository = new InMemoryTachographCompositeSessionRepository();
TachographFileSessionRepository fileSessionRepository = Mockito.mock(TachographFileSessionRepository.class);
TachographCompositeSessionService service = new TachographCompositeSessionService(
compositeRepository,
fileSessionRepository,
Mockito.mock(DriverTimelineEventBuilder.class),
Mockito.mock(UnifiedEventTimelineReconstructor.class),
new EventAcquisitionRecordKeyService(),
new EventHubEventSorter()
);
UUID sessionId = UUID.randomUUID();
when(fileSessionRepository.find(sessionId)).thenReturn(java.util.Optional.of(
session(sessionId, driver("12:123", "Muster", "Max", "12", "CARD0000000001", 0, 0, List.of()))
));
CreateTachographCompositeSessionResponse created = service.createCompositeSession(
new CreateTachographCompositeSessionRequest(List.of(sessionId), null)
);
assertThatThrownBy(() -> service.getMergedDriverEvents(created.session().compositeSessionId(), "missing"))
.isInstanceOf(DriverNotFoundInCompositeSessionException.class)
.hasMessageContaining("missing");
}
private static TachographFileSession session(UUID sessionId, DriverExtractionSession... drivers) {
return session(sessionId, List.of(), drivers);
}
private static TachographFileSession session(
UUID sessionId,
List<ExtractionWarning> warnings,
DriverExtractionSession... drivers
) {
Map<String, DriverExtractionSession> byKey = java.util.Arrays.stream(drivers)
.collect(java.util.stream.Collectors.toMap(DriverExtractionSession::driverKey, driver -> driver));
return new TachographFileSession(
sessionId,
new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "sha", 3, "42", "xml", true, null),
byKey,
new ExtractionStats(byKey.size(), 0, 0, 0, 0, warnings.size()),
warnings,
Instant.parse("2026-05-22T08:00:00Z"),
Instant.parse("2026-05-22T12:00:00Z")
);
}
private static DriverExtractionSession driver(
String driverKey,
String surname,
String firstNames,
String cardNation,
String cardNumber,
int activityIntervalCount,
int cardVehicleUsageIntervalCount,
List<ExtractionWarning> warnings
) {
return new DriverExtractionSession(
driverKey,
new ExtractedDriver(driverKey, "SOURCE:" + driverKey, surname, firstNames, LocalDate.parse("1980-01-01"), null, null, null, null),
new ExtractedDriverCard("CARD:" + driverKey, cardNation, cardNumber, cardNumber + "01", null, null, null, null),
List.of(),
List.of(),
java.util.Collections.nCopies(cardVehicleUsageIntervalCount, null),
java.util.Collections.nCopies(activityIntervalCount, null),
List.of(),
warnings
);
}
private static EventHubEventDto event(String externalSourceEventId, OffsetDateTime occurredAt, EventType eventType) {
return new EventHubEventDto(
null,
externalSourceEventId,
new DriverRefDto(null, new DriverCardRefDto("12", 12, "CARD0000000001")),
new VehicleRefDto(null, "VIN00000000000001", null, new VehicleRegistrationRefDto("12", 12, "W-12345A")),
occurredAt,
null,
null,
eventType == EventType.DRIVE ? EventDomain.DRIVER_ACTIVITY : EventDomain.DRIVER_ACTIVITY,
eventType,
eventType == EventType.BREAK_REST ? EventLifecycle.END : EventLifecycle.START,
null,
null,
null,
null,
null,
false,
null
);
}
}

View File

@ -391,10 +391,9 @@ class TachographFileSessionProcessingServiceTest {
assertThat(result.dailyWeeklyRestCandidateIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:00Z")); assertThat(result.dailyWeeklyRestCandidateIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:00Z"));
assertThat(result.dailyWeeklyRestCandidateIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T23:00:00Z")); assertThat(result.dailyWeeklyRestCandidateIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T23:00:00Z"));
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:00Z")); assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:00Z"));
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).unknownDurationSeconds()).isEqualTo(43_200L); assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentDurationSeconds()).isEqualTo(50_399L);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).unknownCoveragePercent()).isEqualTo(100.0d); assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentCoveragePercent())
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentDurationSeconds()).isEqualTo(0L); .isCloseTo(99.998d, org.assertj.core.data.Offset.offset(0.001d));
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentCoveragePercent()).isEqualTo(0.0d);
assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:00Z")); assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:00Z"));
assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).previousRegistrationKey()).isEqualTo("12:REG-1"); assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).previousRegistrationKey()).isEqualTo("12:REG-1");
assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).nextRegistrationKey()).isEqualTo("12:REG-2"); assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).nextRegistrationKey()).isEqualTo("12:REG-2");
@ -403,10 +402,9 @@ class TachographFileSessionProcessingServiceTest {
assertThat(result.potentialInVehicleTripIntervalCount()).isEqualTo(0); assertThat(result.potentialInVehicleTripIntervalCount()).isEqualTo(0);
assertThat(result.potentialHomeOvernightStayIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:00Z")); assertThat(result.potentialHomeOvernightStayIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:00Z"));
assertThat(result.potentialHomeOvernightStayIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T23:00:00Z")); assertThat(result.potentialHomeOvernightStayIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T23:00:00Z"));
assertThat(result.potentialHomeOvernightStayIntervals().get(0).cardPresentDurationSeconds()).isEqualTo(0L); assertThat(result.potentialHomeOvernightStayIntervals().get(0).cardAbsentDurationSeconds()).isEqualTo(50_399L);
assertThat(result.potentialHomeOvernightStayIntervals().get(0).cardPresentCoveragePercent()).isEqualTo(0.0d); assertThat(result.potentialHomeOvernightStayIntervals().get(0).cardAbsentCoveragePercent())
assertThat(result.potentialHomeOvernightStayIntervals().get(0).unknownDurationSeconds()).isEqualTo(43_200L); .isCloseTo(99.998d, org.assertj.core.data.Offset.offset(0.001d));
assertThat(result.potentialHomeOvernightStayIntervals().get(0).unknownCoveragePercent()).isEqualTo(100.0d);
} }
@Test @Test
@ -483,15 +481,11 @@ class TachographFileSessionProcessingServiceTest {
assertThat(result.potentialHomeOvernightStayIntervalCount()).isEqualTo(0); assertThat(result.potentialHomeOvernightStayIntervalCount()).isEqualTo(0);
assertThat(result.potentialInVehicleOvernightStayIntervalCount()).isEqualTo(1); assertThat(result.potentialInVehicleOvernightStayIntervalCount()).isEqualTo(1);
assertThat(result.potentialInVehicleTripIntervalCount()).isEqualTo(0); assertThat(result.potentialInVehicleTripIntervalCount()).isEqualTo(0);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentDurationSeconds()).isEqualTo(50_400L); assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentDurationSeconds()).isEqualTo(0L);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentCoveragePercent()).isEqualTo(100.0d);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).unknownDurationSeconds()).isEqualTo(0L);
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z")); assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z"));
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-02T00:00:00Z")); assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-02T00:00:00Z"));
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).cardPresentDurationSeconds()).isEqualTo(50_400L); assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).cardAbsentDurationSeconds()).isEqualTo(0L);
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).cardPresentCoveragePercent()).isEqualTo(100.0d); assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).cardAbsentCoveragePercent()).isEqualTo(0.0d);
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).unknownDurationSeconds()).isEqualTo(0L);
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).unknownCoveragePercent()).isEqualTo(0.0d);
} }
@Test @Test
@ -580,9 +574,7 @@ class TachographFileSessionProcessingServiceTest {
.isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z")); .isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z"));
assertThat(result.unclassifiedDailyWeeklyRestCandidateCoverageIntervals().get(0).endedAt()) assertThat(result.unclassifiedDailyWeeklyRestCandidateCoverageIntervals().get(0).endedAt())
.isEqualTo(OffsetDateTime.parse("2026-05-02T00:30:00Z")); .isEqualTo(OffsetDateTime.parse("2026-05-02T00:30:00Z"));
assertThat(result.unclassifiedDailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentCoveragePercent()) assertThat(result.unclassifiedDailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentCoveragePercent())
.isLessThan(95.0d);
assertThat(result.unclassifiedDailyWeeklyRestCandidateCoverageIntervals().get(0).unknownCoveragePercent())
.isLessThan(95.0d); .isLessThan(95.0d);
} }