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 endedAt,
long durationSeconds,
long cardPresentDurationSeconds,
double cardPresentCoveragePercent,
long unknownDurationSeconds,
double unknownCoveragePercent,
long cardAbsentDurationSeconds,
double cardAbsentCoveragePercent,
String previousDrivingSourceIntervalId,
String nextDrivingSourceIntervalId,
String previousRegistrationKey,

View File

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

View File

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

View File

@ -14,8 +14,7 @@ public record TachographEsperPotentialInVehicleTripIntervalEvent(
String vehicleKey,
int containedPotentialInVehicleOvernightStayIntervalCount,
long containedPotentialInVehicleOvernightStayDurationSeconds,
long containedCardPresentDurationSeconds,
long containedUnknownDurationSeconds,
long containedCardAbsentDurationSeconds,
OffsetDateTime firstPotentialInVehicleOvernightStayStartedAt,
OffsetDateTime lastPotentialInVehicleOvernightStayEndedAt,
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("endedAt"),
(Long) event.get("durationSeconds"),
0L,
0.0d,
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(Long) event.get("cardAbsentDurationSeconds"),
(Double) event.get("cardAbsentCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"),

View File

@ -35,6 +35,7 @@ import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
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(
UUID sessionId,
String driverKey,
@ -87,27 +136,23 @@ public class DriverTimelineReusableProjectionBuilder {
return emptyBundle();
}
return buildEsperDrivingDerivedProjectionBundle(
sessionId,
driverKey,
timeline.activityIntervals(),
timeline.vehicleUsageIntervals(),
timeline.supportEvents(),
buildActivityIntervalInputEvents(sessionId, driverKey, timeline.activityIntervals()),
buildVehicleUsageIntervalInputEvents(timeline.vehicleUsageIntervals()),
buildSupportGeoInputEvents(sessionId, timeline.supportEvents()),
significantDrivingMinutes,
minimumRestPeriodMinutes
);
}
private TachographEsperDrivingDerivedProjectionBundle buildEsperDrivingDerivedProjectionBundle(
UUID sessionId,
String driverKey,
List<ResolvedActivityInterval> activityIntervals,
List<ResolvedVehicleUsageInterval> vehicleUsageIntervals,
List<ExtractedSupportEvent> supportEvents,
List<Map<String, Object>> activityInputEvents,
List<Map<String, Object>> vehicleUsageInputEvents,
List<Map<String, Object>> supportGeoInputEvents,
int significantDrivingMinutes,
int minimumRestPeriodMinutes
) {
if ((activityIntervals == null || activityIntervals.isEmpty())
&& (vehicleUsageIntervals == null || vehicleUsageIntervals.isEmpty())) {
if ((activityInputEvents == null || activityInputEvents.isEmpty())
&& (vehicleUsageInputEvents == null || vehicleUsageInputEvents.isEmpty())) {
return emptyBundle();
}
@ -149,29 +194,26 @@ public class DriverTimelineReusableProjectionBuilder {
"potentialInVehicleTripIntervals", newData -> collectPotentialInVehicleTripIntervalEvents(newData, potentialInVehicleTripIntervals)
),
runtime -> {
if (supportEvents != null) {
for (ExtractedSupportEvent supportEvent : supportEvents) {
Map<String, Object> supportGeoEvidence = toSupportGeoEvidenceInputMap(sessionId, supportEvent);
if (supportGeoEvidence != null) {
runtime.getEventService().sendEventMap(
supportGeoEvidence,
"TachographSupportGeoEvidenceInputEvent"
);
}
if (supportGeoInputEvents != null) {
for (Map<String, Object> supportGeoEvidence : supportGeoInputEvents) {
runtime.getEventService().sendEventMap(
supportGeoEvidence,
"TachographSupportGeoEvidenceInputEvent"
);
}
}
if (vehicleUsageIntervals != null) {
for (ResolvedVehicleUsageInterval interval : vehicleUsageIntervals) {
if (vehicleUsageInputEvents != null) {
for (Map<String, Object> interval : vehicleUsageInputEvents) {
runtime.getEventService().sendEventMap(
toVehicleUsageIntervalInputMap(interval),
interval,
"TachographVehicleUsageIntervalInputEvent"
);
}
}
if (activityIntervals != null) {
for (ResolvedActivityInterval interval : activityIntervals) {
if (activityInputEvents != null) {
for (Map<String, Object> interval : activityInputEvents) {
runtime.getEventService().sendEventMap(
toActivityIntervalInputMap(sessionId, driverKey, interval),
interval,
"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() {
return new TachographEsperDrivingDerivedProjectionBundle(
List.of(),
@ -503,10 +589,8 @@ public class DriverTimelineReusableProjectionBuilder {
OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC),
(Long) event.get("durationSeconds"),
(Long) event.get("cardPresentDurationSeconds"),
(Double) event.get("cardPresentCoveragePercent"),
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(Long) event.get("cardAbsentDurationSeconds"),
(Double) event.get("cardAbsentCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"),
@ -571,10 +655,8 @@ public class DriverTimelineReusableProjectionBuilder {
OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC),
(Long) event.get("durationSeconds"),
(Long) event.get("cardPresentDurationSeconds"),
(Double) event.get("cardPresentCoveragePercent"),
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(Long) event.get("cardAbsentDurationSeconds"),
(Double) event.get("cardAbsentCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"),
@ -639,10 +721,8 @@ public class DriverTimelineReusableProjectionBuilder {
OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC),
(Long) event.get("durationSeconds"),
(Long) event.get("cardPresentDurationSeconds"),
(Double) event.get("cardPresentCoveragePercent"),
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(Long) event.get("cardAbsentDurationSeconds"),
(Double) event.get("cardAbsentCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"),
@ -693,8 +773,7 @@ public class DriverTimelineReusableProjectionBuilder {
(String) event.get("vehicleKey"),
(Integer) event.get("containedPotentialInVehicleOvernightStayIntervalCount"),
(Long) event.get("containedPotentialInVehicleOvernightStayDurationSeconds"),
(Long) event.get("containedCardPresentDurationSeconds"),
(Long) event.get("containedUnknownDurationSeconds"),
(Long) event.get("containedCardAbsentDurationSeconds"),
OffsetDateTime.ofInstant(Instant.ofEpochSecond((Long) event.get("firstPotentialInVehicleOvernightStayStartedAtEpochSecond")), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond((Long) event.get("lastPotentialInVehicleOvernightStayEndedAtEpochSecond")), ZoneOffset.UTC),
(String) event.get("firstPreviousDrivingSourceIntervalId"),
@ -725,7 +804,7 @@ public class DriverTimelineReusableProjectionBuilder {
private List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> sortDailyWeeklyRestCandidateCoverageIntervals(
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> intervals
) {
return intervals.stream()
return deduplicateRestCoverageIntervals(intervals).stream()
.sorted(Comparator.comparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::startedAt)
.thenComparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::endedAt))
.toList();
@ -734,7 +813,7 @@ public class DriverTimelineReusableProjectionBuilder {
private List<TachographEsperPotentialHomeOvernightStayIntervalEvent> sortPotentialHomeOvernightStayIntervals(
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> intervals
) {
return intervals.stream()
return deduplicatePotentialHomeOvernightStayIntervals(intervals).stream()
.sorted(Comparator.comparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::endedAt))
.toList();
@ -743,12 +822,76 @@ public class DriverTimelineReusableProjectionBuilder {
private List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> sortPotentialInVehicleOvernightStayIntervals(
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> intervals
) {
return intervals.stream()
return deduplicatePotentialInVehicleOvernightStayIntervals(intervals).stream()
.sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt))
.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(
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;
}
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 endBoundaryChanged = !end.equals(interval.endedAt());
return new TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent(
@ -553,16 +534,16 @@ public class TachographFileSessionProcessingService {
start,
end,
durationSeconds,
cardPresentDurationSeconds,
cardPresentCoveragePercent,
unknownDurationSeconds,
unknownCoveragePercent,
interval.cardAbsentDurationSeconds(),
interval.cardAbsentCoveragePercent(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey(),
beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(),
endBoundaryChanged ? null : interval.endBoundaryOdometerKm(),
beginBoundaryChanged ? null : interval.beginGeoEventId(),
beginBoundaryChanged ? null : interval.beginGeoEventDomain(),
beginBoundaryChanged ? null : interval.beginGeoOccurredAt(),
@ -607,25 +588,6 @@ public class TachographFileSessionProcessingService {
return null;
}
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 endBoundaryChanged = !end.equals(interval.endedAt());
return new TachographEsperPotentialHomeOvernightStayIntervalEvent(
@ -634,16 +596,16 @@ public class TachographFileSessionProcessingService {
start,
end,
durationSeconds,
cardPresentDurationSeconds,
cardPresentCoveragePercent,
unknownDurationSeconds,
unknownCoveragePercent,
interval.cardAbsentDurationSeconds(),
interval.cardAbsentCoveragePercent(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey(),
beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(),
endBoundaryChanged ? null : interval.endBoundaryOdometerKm(),
beginBoundaryChanged ? null : interval.beginGeoEventId(),
beginBoundaryChanged ? null : interval.beginGeoEventDomain(),
beginBoundaryChanged ? null : interval.beginGeoOccurredAt(),
@ -688,25 +650,6 @@ public class TachographFileSessionProcessingService {
return null;
}
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 endBoundaryChanged = !end.equals(interval.endedAt());
return new TachographEsperPotentialInVehicleOvernightStayIntervalEvent(
@ -715,16 +658,16 @@ public class TachographFileSessionProcessingService {
start,
end,
durationSeconds,
cardPresentDurationSeconds,
cardPresentCoveragePercent,
unknownDurationSeconds,
unknownCoveragePercent,
interval.cardAbsentDurationSeconds(),
interval.cardAbsentCoveragePercent(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey(),
beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(),
endBoundaryChanged ? null : interval.endBoundaryOdometerKm(),
beginBoundaryChanged ? null : interval.beginGeoEventId(),
beginBoundaryChanged ? null : interval.beginGeoEventDomain(),
beginBoundaryChanged ? null : interval.beginGeoOccurredAt(),
@ -803,10 +746,7 @@ public class TachographFileSessionProcessingService {
.mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::durationSeconds)
.sum(),
containedIntervals.stream()
.mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::cardPresentDurationSeconds)
.sum(),
containedIntervals.stream()
.mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::unknownDurationSeconds)
.mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::cardAbsentDurationSeconds)
.sum(),
first.startedAt(),
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 vehicle-change intervals are daily/weekly rest candidates where previousRegistrationKey differs from nextRegistrationKey.",
"Daily/weekly rest candidate intervals are driving interruption intervals longer than the configured minimum rest-period threshold.",
"Daily/weekly rest candidate coverage intervals enrich each rest candidate with card-present and 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.",
"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.",

View File

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

View File

@ -15,7 +15,7 @@ select
then c.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond
end
) as unknownDurationSeconds,
) as cardAbsentDurationSeconds,
(sum(
case
when u.startedAtEpochSecond <= c.startedAtEpochSecond and u.endedAtEpochSecond >= c.endedAtEpochSecond
@ -26,7 +26,7 @@ select
then c.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond
end
) * 100.0d) / c.durationSeconds as unknownCoveragePercent,
) * 100.0d) / c.durationSeconds as cardAbsentCoveragePercent,
c.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
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-12T22:00:00Z"),
43_200L,
0L,
0.0d,
43_200L,
100.0d,
"ACT-2",
@ -216,8 +214,6 @@ class TachographFileSessionControllerTest {
OffsetDateTime.parse("2026-05-12T10:00:00Z"),
OffsetDateTime.parse("2026-05-12T22:00:00Z"),
43_200L,
0L,
0.0d,
43_200L,
100.0d,
"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).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-02T00:00:00Z"));
assertThat(intervals.get(0).durationSeconds()).isEqualTo(50_400L);
assertThat(intervals.get(0).unknownDurationSeconds()).isEqualTo(50_399L);
assertThat(intervals.get(0).unknownCoveragePercent()).isGreaterThan(99.9d);
assertThat(intervals.get(0).cardAbsentDurationSeconds()).isEqualTo(50_399L);
assertThat(intervals.get(0).cardAbsentCoveragePercent()).isGreaterThan(99.9d);
assertThat(intervals.get(0).previousDrivingSourceIntervalId()).isEqualTo("ACT-1");
assertThat(intervals.get(0).nextDrivingSourceIntervalId()).isEqualTo("ACT-2");
}

View File

@ -128,8 +128,7 @@ class DriverTimelineReusableProjectionBuilderTest {
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1);
TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent coverageInterval =
reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0);
assertThat(coverageInterval.unknownCoveragePercent()).isCloseTo(99.998d, org.assertj.core.data.Offset.offset(0.001d));
assertThat(coverageInterval.cardPresentCoveragePercent()).isEqualTo(0.0d);
assertThat(coverageInterval.cardAbsentCoveragePercent()).isCloseTo(99.998d, org.assertj.core.data.Offset.offset(0.001d));
assertThat(reusableBundle.drivingInterruptionVehicleChangeIntervals()).containsExactlyElementsOf(legacyDrivingInterruptionVehicleChanges);
assertThat(reusableBundle.vuCardAbsentIntervals()).containsExactlyElementsOf(legacyVuCardAbsentIntervals);
assertThat(reusableBundle.potentialHomeOvernightStayIntervals()).hasSize(1);
@ -137,8 +136,8 @@ class DriverTimelineReusableProjectionBuilderTest {
.isEqualTo(legacyPotentialHomeOvernightStays.get(0).startedAt());
assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).endedAt())
.isEqualTo(legacyPotentialHomeOvernightStays.get(0).endedAt());
assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).unknownCoveragePercent())
.isEqualTo(legacyPotentialHomeOvernightStays.get(0).unknownCoveragePercent());
assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).cardAbsentCoveragePercent())
.isEqualTo(legacyPotentialHomeOvernightStays.get(0).cardAbsentCoveragePercent());
assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).beginBoundaryOdometerKm())
.isEqualTo(200L);
assertThat(reusableBundle.potentialHomeOvernightStayIntervals().get(0).endBoundaryOdometerKm())
@ -266,9 +265,7 @@ class DriverTimelineReusableProjectionBuilderTest {
assertThat(reusableBundle.drivingInterruptionVehicleChangeIntervals()).isEmpty();
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals()).hasSize(1);
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentCoveragePercent())
.isEqualTo(100.0d);
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0).unknownCoveragePercent())
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentCoveragePercent())
.isEqualTo(0.0d);
assertThat(reusableBundle.dailyWeeklyRestCandidateCoverageIntervals().get(0).beginGeoEventId())
.isEqualTo("SUP-1");
@ -296,8 +293,6 @@ class DriverTimelineReusableProjectionBuilderTest {
.isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z"));
assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).endedAt())
.isEqualTo(OffsetDateTime.parse("2026-05-02T00:00:00Z"));
assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).cardPresentCoveragePercent())
.isEqualTo(100.0d);
assertThat(reusableBundle.potentialInVehicleOvernightStayIntervals().get(0).beginGeoEventId())
.isEqualTo("SUP-1");
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).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).unknownDurationSeconds()).isEqualTo(43_200L);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).unknownCoveragePercent()).isEqualTo(100.0d);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentDurationSeconds()).isEqualTo(0L);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentCoveragePercent()).isEqualTo(0.0d);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentDurationSeconds()).isEqualTo(50_399L);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentCoveragePercent())
.isCloseTo(99.998d, org.assertj.core.data.Offset.offset(0.001d));
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).nextRegistrationKey()).isEqualTo("12:REG-2");
@ -403,10 +402,9 @@ class TachographFileSessionProcessingServiceTest {
assertThat(result.potentialInVehicleTripIntervalCount()).isEqualTo(0);
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).cardPresentDurationSeconds()).isEqualTo(0L);
assertThat(result.potentialHomeOvernightStayIntervals().get(0).cardPresentCoveragePercent()).isEqualTo(0.0d);
assertThat(result.potentialHomeOvernightStayIntervals().get(0).unknownDurationSeconds()).isEqualTo(43_200L);
assertThat(result.potentialHomeOvernightStayIntervals().get(0).unknownCoveragePercent()).isEqualTo(100.0d);
assertThat(result.potentialHomeOvernightStayIntervals().get(0).cardAbsentDurationSeconds()).isEqualTo(50_399L);
assertThat(result.potentialHomeOvernightStayIntervals().get(0).cardAbsentCoveragePercent())
.isCloseTo(99.998d, org.assertj.core.data.Offset.offset(0.001d));
}
@Test
@ -483,15 +481,11 @@ class TachographFileSessionProcessingServiceTest {
assertThat(result.potentialHomeOvernightStayIntervalCount()).isEqualTo(0);
assertThat(result.potentialInVehicleOvernightStayIntervalCount()).isEqualTo(1);
assertThat(result.potentialInVehicleTripIntervalCount()).isEqualTo(0);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentDurationSeconds()).isEqualTo(50_400L);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentCoveragePercent()).isEqualTo(100.0d);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).unknownDurationSeconds()).isEqualTo(0L);
assertThat(result.dailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentDurationSeconds()).isEqualTo(0L);
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).cardPresentDurationSeconds()).isEqualTo(50_400L);
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).cardPresentCoveragePercent()).isEqualTo(100.0d);
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).unknownDurationSeconds()).isEqualTo(0L);
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).unknownCoveragePercent()).isEqualTo(0.0d);
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).cardAbsentDurationSeconds()).isEqualTo(0L);
assertThat(result.potentialInVehicleOvernightStayIntervals().get(0).cardAbsentCoveragePercent()).isEqualTo(0.0d);
}
@Test
@ -580,9 +574,7 @@ class TachographFileSessionProcessingServiceTest {
.isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z"));
assertThat(result.unclassifiedDailyWeeklyRestCandidateCoverageIntervals().get(0).endedAt())
.isEqualTo(OffsetDateTime.parse("2026-05-02T00:30:00Z"));
assertThat(result.unclassifiedDailyWeeklyRestCandidateCoverageIntervals().get(0).cardPresentCoveragePercent())
.isLessThan(95.0d);
assertThat(result.unclassifiedDailyWeeklyRestCandidateCoverageIntervals().get(0).unknownCoveragePercent())
assertThat(result.unclassifiedDailyWeeklyRestCandidateCoverageIntervals().get(0).cardAbsentCoveragePercent())
.isLessThan(95.0d);
}