From 04d7bf513e0a1ba766457b1c88d07c2cbfdbe14a Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Wed, 20 May 2026 10:57:36 +0200 Subject: [PATCH] Add unified event processing sources --- .../EventHubEventReadRepository.java | 389 ++++++++++++++++++ .../model/UnifiedDriverEventsRequest.java | 149 +++++++ .../model/UnifiedDriverTimelineRequest.java | 30 ++ .../model/UnifiedEventSourceFamily.java | 7 + .../TachographDbUnifiedDriverEventSource.java | 30 ++ ...phFileSessionUnifiedDriverEventSource.java | 61 +++ ...ileSessionUnifiedDriverTimelineSource.java | 43 ++ .../service/UnifiedDriverEventSource.java | 12 + .../UnifiedDriverEventSourceService.java | 28 ++ .../service/UnifiedDriverTimelineService.java | 28 ++ .../service/UnifiedDriverTimelineSource.java | 11 + .../YellowFoxDbUnifiedDriverEventSource.java | 28 ++ .../model/UnifiedDriverEventsRequestTest.java | 88 ++++ .../UnifiedDriverEventSourceServiceTest.java | 165 ++++++++ .../UnifiedDriverTimelineServiceTest.java | 150 +++++++ 15 files changed, 1219 insertions(+) create mode 100644 src/main/java/at/procon/eventhub/persistence/EventHubEventReadRepository.java create mode 100644 src/main/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequest.java create mode 100644 src/main/java/at/procon/eventhub/processing/model/UnifiedDriverTimelineRequest.java create mode 100644 src/main/java/at/procon/eventhub/processing/model/UnifiedEventSourceFamily.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/TachographDbUnifiedDriverEventSource.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverEventSource.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverTimelineSource.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/UnifiedDriverEventSource.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/UnifiedDriverEventSourceService.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineService.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineSource.java create mode 100644 src/main/java/at/procon/eventhub/processing/service/YellowFoxDbUnifiedDriverEventSource.java create mode 100644 src/test/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequestTest.java create mode 100644 src/test/java/at/procon/eventhub/processing/service/UnifiedDriverEventSourceServiceTest.java create mode 100644 src/test/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineServiceTest.java diff --git a/src/main/java/at/procon/eventhub/persistence/EventHubEventReadRepository.java b/src/main/java/at/procon/eventhub/persistence/EventHubEventReadRepository.java new file mode 100644 index 0000000..221d90d --- /dev/null +++ b/src/main/java/at/procon/eventhub/persistence/EventHubEventReadRepository.java @@ -0,0 +1,389 @@ +package at.procon.eventhub.persistence; + +import at.procon.eventhub.dto.DriverCardRefDto; +import at.procon.eventhub.dto.DriverRefDto; +import at.procon.eventhub.dto.EventDetailsDto; +import at.procon.eventhub.dto.EventDomain; +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.dto.EventHubPackageRequest; +import at.procon.eventhub.dto.EventLifecycle; +import at.procon.eventhub.dto.EventSourceDto; +import at.procon.eventhub.dto.EventType; +import at.procon.eventhub.dto.GeoPointDto; +import at.procon.eventhub.dto.ImportScopeDto; +import at.procon.eventhub.dto.ImportScopeType; +import at.procon.eventhub.dto.SourceGroupRefDto; +import at.procon.eventhub.dto.SourceGroupType; +import at.procon.eventhub.dto.SourcePackageRefDto; +import at.procon.eventhub.dto.VehicleRefDto; +import at.procon.eventhub.dto.VehicleRegistrationRefDto; +import at.procon.eventhub.processing.model.UnifiedDriverEventsRequest; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class EventHubEventReadRepository { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + public EventHubEventReadRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + } + + public List findEvents( + UnifiedDriverEventsRequest request, + String providerKey, + List sourceKinds + ) { + StringBuilder sql = new StringBuilder( + """ + select + event.id, + event.external_source_event_id, + event.occurred_at, + event.received_partner_at, + event.received_hub_at, + event.event_domain, + event.event_type, + event.lifecycle, + event.odometer_m, + ST_Y(event.position::geometry) as latitude, + ST_X(event.position::geometry) as longitude, + event.payload, + event.manual_entry, + source.provider_key, + source.source_kind, + source.source_key, + source.source_instance_key, + source.tenant_provider_setting_key, + source.external_fleet_key, + package.id as data_package_id, + package.tenant_key, + package.event_family, + package.business_date, + package.external_package_id, + package.source_group_type, + package.source_group_entity_id, + package.source_group_code, + package.source_group_name, + package.import_scope_type, + package.root_source_org_entity_id, + package.root_source_org_code, + package.root_source_org_name, + package.include_children, + package.occurred_from as package_occurred_from, + package.occurred_to as package_occurred_to, + package.source_package_kind as package_source_package_kind, + package.source_package_period_from, + package.source_package_period_to, + package.source_package_imported_at, + event.source_package_id, + event.source_package_entity_id, + detail.detail_type, + detail.attributes, + driver.source_driver_entity_id, + driver_card.nation as driver_card_nation, + driver_card.card_number as driver_card_number, + vehicle.source_vehicle_entity_id, + vehicle.vin, + registration.source_registration_entity_id, + registration.nation as vehicle_registration_nation, + registration.registration_number as vehicle_registration_number, + event.driver_id, + event.vehicle_id, + event.vehicle_registration_id + from eventhub.event event + join eventhub.event_source source on source.id = event.event_source_id + join eventhub.data_package package on package.id = event.data_package_id + left join lateral ( + select detail_type, attributes + from eventhub.event_detail detail + where detail.event_occurred_at = event.occurred_at + and detail.event_id = event.id + order by detail_type + limit 1 + ) detail on true + left join eventhub.driver driver on driver.id = event.driver_id + left join eventhub.driver_card driver_card on driver_card.id = event.driver_card_id + left join eventhub.vehicle vehicle on vehicle.id = event.vehicle_id + left join eventhub.vehicle_registration registration on registration.id = event.vehicle_registration_id + where package.tenant_key = ? + and source.provider_key = ? + """ + ); + + List params = new ArrayList<>(); + params.add(request.tenantKey()); + params.add(providerKey); + + if (sourceKinds != null && !sourceKinds.isEmpty()) { + sql.append(" and source.source_kind in ("); + for (int i = 0; i < sourceKinds.size(); i++) { + if (i > 0) { + sql.append(", "); + } + sql.append("?"); + params.add(sourceKinds.get(i)); + } + sql.append(")"); + } + if (request.occurredFrom() != null) { + sql.append(" and event.occurred_at >= ?"); + params.add(request.occurredFrom()); + } + if (request.occurredTo() != null) { + sql.append(" and event.occurred_at <= ?"); + params.add(request.occurredTo()); + } + if (request.driverSourceEntityId() != null) { + sql.append(" and driver.source_driver_entity_id = ?"); + params.add(request.driverSourceEntityId()); + } + if (request.driverCardNumber() != null) { + sql.append(" and driver_card.card_number = ?"); + params.add(request.driverCardNumber()); + if (request.driverCardNation() != null) { + sql.append(" and driver_card.nation = ?"); + params.add(request.driverCardNation()); + } + } + if (request.vehicleSourceEntityId() != null) { + sql.append(" and vehicle.source_vehicle_entity_id = ?"); + params.add(request.vehicleSourceEntityId()); + } + if (request.vin() != null) { + sql.append(" and vehicle.vin = ?"); + params.add(request.vin()); + } + if (request.registrationNumber() != null) { + sql.append(" and registration.registration_number = ?"); + params.add(request.registrationNumber()); + if (request.registrationNation() != null) { + sql.append(" and registration.nation = ?"); + params.add(request.registrationNation()); + } + } + + sql.append(" order by event.occurred_at, event.event_domain, event.event_type, event.lifecycle, event.id"); + + return jdbcTemplate.query( + sql.toString(), + (rs, rowNum) -> mapEvent(rs), + params.toArray() + ); + } + + private EventHubEventDto mapEvent(ResultSet rs) throws SQLException { + DriverRefDto driverRef = driverRef(rs); + VehicleRefDto vehicleRef = vehicleRef(rs); + if ((driverRef == null || !driverRef.hasAnyReference()) && (vehicleRef == null || !vehicleRef.hasAnyReference())) { + throw new IllegalStateException("Loaded event does not have any driver or vehicle reference."); + } + + return new EventHubEventDto( + uuid(rs, "id"), + rs.getString("external_source_event_id"), + driverRef, + vehicleRef, + rs.getObject("occurred_at", OffsetDateTime.class), + rs.getObject("received_partner_at", OffsetDateTime.class), + rs.getObject("received_hub_at", OffsetDateTime.class), + enumValue(EventDomain.class, rs.getString("event_domain"), EventDomain.TELEMATICS_DATA), + enumValue(EventType.class, rs.getString("event_type"), EventType.UNKNOWN_EVENT), + enumValue(EventLifecycle.class, rs.getString("lifecycle"), EventLifecycle.SNAPSHOT), + longValue(rs, "odometer_m"), + point(rs), + eventDetails(rs), + sourcePackageRef(rs), + json(rs.getString("payload")), + rs.getBoolean("manual_entry"), + packageInfo(rs) + ); + } + + private DriverRefDto driverRef(ResultSet rs) throws SQLException { + String sourceEntityId = firstNonBlank( + rs.getString("source_driver_entity_id"), + syntheticId("EVENTHUB_DRIVER", uuid(rs, "driver_id")) + ); + String cardNumber = rs.getString("driver_card_number"); + DriverCardRefDto driverCard = cardNumber == null + ? null + : new DriverCardRefDto(rs.getString("driver_card_nation"), cardNumber); + DriverRefDto driverRef = new DriverRefDto(sourceEntityId, driverCard); + return driverRef.hasAnyReference() ? driverRef : null; + } + + private VehicleRefDto vehicleRef(ResultSet rs) throws SQLException { + VehicleRegistrationRefDto registration = null; + String registrationNumber = rs.getString("vehicle_registration_number"); + if (registrationNumber != null) { + registration = new VehicleRegistrationRefDto(rs.getString("vehicle_registration_nation"), registrationNumber); + } + VehicleRefDto vehicleRef = new VehicleRefDto( + firstNonBlank( + rs.getString("source_vehicle_entity_id"), + syntheticId("EVENTHUB_VEHICLE", uuid(rs, "vehicle_id")) + ), + rs.getString("vin"), + firstNonBlank( + rs.getString("source_registration_entity_id"), + syntheticId("EVENTHUB_VEHICLE_REGISTRATION", uuid(rs, "vehicle_registration_id")) + ), + registration + ); + return vehicleRef.hasAnyReference() ? vehicleRef : null; + } + + private EventDetailsDto eventDetails(ResultSet rs) throws SQLException { + String detailType = rs.getString("detail_type"); + if (detailType == null || detailType.isBlank()) { + return null; + } + return new EventDetailsDto(detailType, json(rs.getString("attributes"))); + } + + private SourcePackageRefDto sourcePackageRef(ResultSet rs) throws SQLException { + String packageKind = rs.getString("package_source_package_kind"); + String sourcePackageId = firstNonBlank(rs.getString("source_package_id"), null); + String sourceEntityId = firstNonBlank(rs.getString("source_package_entity_id"), null); + OffsetDateTime periodFrom = rs.getObject("source_package_period_from", OffsetDateTime.class); + OffsetDateTime periodTo = rs.getObject("source_package_period_to", OffsetDateTime.class); + OffsetDateTime importedAt = rs.getObject("source_package_imported_at", OffsetDateTime.class); + SourcePackageRefDto ref = new SourcePackageRefDto( + packageKind, + sourcePackageId, + sourceEntityId, + periodFrom, + periodTo, + importedAt + ); + return ref.hasAnyReference() ? ref : null; + } + + private EventHubPackageRequest packageInfo(ResultSet rs) throws SQLException { + EventSourceDto eventSource = new EventSourceDto( + rs.getString("provider_key"), + rs.getString("source_kind"), + rs.getString("source_key"), + rs.getString("source_instance_key"), + rs.getString("tenant_provider_setting_key"), + rs.getString("external_fleet_key") + ); + SourceGroupRefDto sourceGroup = sourceGroup(rs); + ImportScopeDto importScope = importScope(rs); + String eventFamily = firstNonBlank(rs.getString("event_family"), rs.getString("event_domain")); + LocalDate businessDate = rs.getObject("business_date", LocalDate.class); + String externalPackageId = firstNonBlank(rs.getString("external_package_id"), rs.getString("data_package_id")); + return new EventHubPackageRequest( + rs.getString("tenant_key"), + eventSource, + sourceGroup, + importScope, + eventFamily, + businessDate, + externalPackageId + ); + } + + private SourceGroupRefDto sourceGroup(ResultSet rs) throws SQLException { + String groupType = rs.getString("source_group_type"); + String sourceEntityId = rs.getString("source_group_entity_id"); + String code = rs.getString("source_group_code"); + String name = rs.getString("source_group_name"); + if (groupType == null && sourceEntityId == null && code == null && name == null) { + return null; + } + return new SourceGroupRefDto( + enumValue(SourceGroupType.class, groupType, null), + sourceEntityId, + code, + name + ); + } + + private ImportScopeDto importScope(ResultSet rs) throws SQLException { + SourceGroupRefDto rootOrganisation = null; + String rootEntityId = rs.getString("root_source_org_entity_id"); + String rootCode = rs.getString("root_source_org_code"); + String rootName = rs.getString("root_source_org_name"); + if (rootEntityId != null || rootCode != null || rootName != null) { + rootOrganisation = new SourceGroupRefDto(SourceGroupType.ORGANISATION, rootEntityId, rootCode, rootName); + } + return new ImportScopeDto( + enumValue(ImportScopeType.class, rs.getString("import_scope_type"), ImportScopeType.TENANT_ALL), + rootOrganisation, + rs.getBoolean("include_children"), + rs.getObject("package_occurred_from", OffsetDateTime.class), + rs.getObject("package_occurred_to", OffsetDateTime.class) + ); + } + + private GeoPointDto point(ResultSet rs) throws SQLException { + BigDecimal latitude = rs.getBigDecimal("latitude"); + BigDecimal longitude = rs.getBigDecimal("longitude"); + return latitude == null || longitude == null ? null : new GeoPointDto(latitude, longitude); + } + + private UUID uuid(ResultSet rs, String column) throws SQLException { + return (UUID) rs.getObject(column); + } + + private Long longValue(ResultSet rs, String column) throws SQLException { + Object value = rs.getObject(column); + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.longValue(); + } + return Long.parseLong(value.toString()); + } + + private JsonNode json(String value) { + try { + return value == null || value.isBlank() + ? objectMapper.createObjectNode() + : objectMapper.readTree(value); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to parse JSON column.", e); + } + } + + private String syntheticId(String prefix, UUID id) { + return id == null ? null : prefix + ":" + id; + } + + private String firstNonBlank(String first, String second) { + if (first != null && !first.isBlank()) { + return first; + } + return second != null && !second.isBlank() ? second : null; + } + + private > T enumValue(Class type, String value, T fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + String normalized = value.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_'); + try { + return Enum.valueOf(type, normalized); + } catch (IllegalArgumentException ignored) { + return fallback; + } + } +} diff --git a/src/main/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequest.java b/src/main/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequest.java new file mode 100644 index 0000000..f1269d7 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequest.java @@ -0,0 +1,149 @@ +package at.procon.eventhub.processing.model; + +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.UUID; + +public record UnifiedDriverEventsRequest( + UnifiedEventSourceFamily sourceFamily, + UUID sessionId, + String driverKey, + String tenantKey, + String driverSourceEntityId, + String driverCardNation, + String driverCardNumber, + String vehicleSourceEntityId, + String vin, + String registrationNation, + String registrationNumber, + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo +) { + public UnifiedDriverEventsRequest { + Objects.requireNonNull(sourceFamily, "sourceFamily must not be null"); + driverKey = normalize(driverKey); + tenantKey = normalize(tenantKey); + driverSourceEntityId = normalize(driverSourceEntityId); + driverCardNation = normalizeUpper(driverCardNation); + driverCardNumber = normalize(driverCardNumber); + vehicleSourceEntityId = normalize(vehicleSourceEntityId); + vin = normalizeUpper(vin); + registrationNation = normalizeUpper(registrationNation); + registrationNumber = normalize(registrationNumber); + if (occurredFrom != null && occurredTo != null && occurredTo.isBefore(occurredFrom)) { + throw new IllegalArgumentException("occurredTo must not be before occurredFrom"); + } + if (sourceFamily == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION) { + Objects.requireNonNull(sessionId, "sessionId must not be null"); + if (driverKey == null) { + throw new IllegalArgumentException("driverKey must not be blank"); + } + } else { + if (tenantKey == null) { + throw new IllegalArgumentException("tenantKey must not be blank"); + } + if (!hasDriverSelector(driverSourceEntityId, driverCardNumber) + && !hasVehicleSelector(vehicleSourceEntityId, vin, registrationNumber)) { + throw new IllegalArgumentException("At least one driver or vehicle selector must be provided."); + } + } + } + + public static UnifiedDriverEventsRequest forTachographFileSession( + UUID sessionId, + String driverKey, + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo + ) { + return new UnifiedDriverEventsRequest( + UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION, + sessionId, + driverKey, + null, + null, + null, + null, + null, + null, + null, + null, + occurredFrom, + occurredTo + ); + } + + public static UnifiedDriverEventsRequest forTachographDbDriver( + String tenantKey, + String driverSourceEntityId, + String driverCardNation, + String driverCardNumber, + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo + ) { + return new UnifiedDriverEventsRequest( + UnifiedEventSourceFamily.TACHOGRAPH_DB, + null, + null, + tenantKey, + driverSourceEntityId, + driverCardNation, + driverCardNumber, + null, + null, + null, + null, + occurredFrom, + occurredTo + ); + } + + public static UnifiedDriverEventsRequest forYellowFoxDbVehicle( + String tenantKey, + String vehicleSourceEntityId, + String vin, + String registrationNation, + String registrationNumber, + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo + ) { + return new UnifiedDriverEventsRequest( + UnifiedEventSourceFamily.YELLOWFOX_DB, + null, + null, + tenantKey, + null, + null, + null, + vehicleSourceEntityId, + vin, + registrationNation, + registrationNumber, + occurredFrom, + occurredTo + ); + } + + public boolean hasDriverSelector() { + return hasDriverSelector(driverSourceEntityId, driverCardNumber); + } + + public boolean hasVehicleSelector() { + return hasVehicleSelector(vehicleSourceEntityId, vin, registrationNumber); + } + + private static boolean hasDriverSelector(String driverSourceEntityId, String driverCardNumber) { + return driverSourceEntityId != null || driverCardNumber != null; + } + + private static boolean hasVehicleSelector(String vehicleSourceEntityId, String vin, String registrationNumber) { + return vehicleSourceEntityId != null || vin != null || registrationNumber != null; + } + + private static String normalize(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static String normalizeUpper(String value) { + return value == null || value.isBlank() ? null : value.trim().toUpperCase(); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/model/UnifiedDriverTimelineRequest.java b/src/main/java/at/procon/eventhub/processing/model/UnifiedDriverTimelineRequest.java new file mode 100644 index 0000000..17b37ec --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/model/UnifiedDriverTimelineRequest.java @@ -0,0 +1,30 @@ +package at.procon.eventhub.processing.model; + +import java.util.Objects; +import java.util.UUID; + +public record UnifiedDriverTimelineRequest( + UnifiedEventSourceFamily sourceFamily, + UUID sessionId, + String driverKey +) { + public UnifiedDriverTimelineRequest { + Objects.requireNonNull(sourceFamily, "sourceFamily must not be null"); + Objects.requireNonNull(sessionId, "sessionId must not be null"); + if (driverKey == null || driverKey.isBlank()) { + throw new IllegalArgumentException("driverKey must not be blank"); + } + driverKey = driverKey.trim(); + } + + public static UnifiedDriverTimelineRequest forTachographFileSession( + UUID sessionId, + String driverKey + ) { + return new UnifiedDriverTimelineRequest( + UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION, + sessionId, + driverKey + ); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/model/UnifiedEventSourceFamily.java b/src/main/java/at/procon/eventhub/processing/model/UnifiedEventSourceFamily.java new file mode 100644 index 0000000..07d658c --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/model/UnifiedEventSourceFamily.java @@ -0,0 +1,7 @@ +package at.procon.eventhub.processing.model; + +public enum UnifiedEventSourceFamily { + TACHOGRAPH_FILE_SESSION, + TACHOGRAPH_DB, + YELLOWFOX_DB +} diff --git a/src/main/java/at/procon/eventhub/processing/service/TachographDbUnifiedDriverEventSource.java b/src/main/java/at/procon/eventhub/processing/service/TachographDbUnifiedDriverEventSource.java new file mode 100644 index 0000000..79bce1c --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/TachographDbUnifiedDriverEventSource.java @@ -0,0 +1,30 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.persistence.EventHubEventReadRepository; +import at.procon.eventhub.processing.model.UnifiedDriverEventsRequest; +import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class TachographDbUnifiedDriverEventSource implements UnifiedDriverEventSource { + + private static final List SOURCE_KINDS = List.of("DRIVER_CARD", "VEHICLE_UNIT"); + + private final EventHubEventReadRepository repository; + + public TachographDbUnifiedDriverEventSource(EventHubEventReadRepository repository) { + this.repository = repository; + } + + @Override + public boolean supports(UnifiedDriverEventsRequest request) { + return request.sourceFamily() == UnifiedEventSourceFamily.TACHOGRAPH_DB; + } + + @Override + public List loadDriverEvents(UnifiedDriverEventsRequest request) { + return repository.findEvents(request, "TACHOGRAPH", SOURCE_KINDS); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverEventSource.java b/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverEventSource.java new file mode 100644 index 0000000..016c899 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverEventSource.java @@ -0,0 +1,61 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.processing.model.UnifiedDriverEventsRequest; +import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.service.DriverNotFoundInSessionException; +import at.procon.eventhub.tachographfilesession.service.DriverTimelineEventBuilder; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionNotFoundException; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository; +import java.time.OffsetDateTime; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class TachographFileSessionUnifiedDriverEventSource implements UnifiedDriverEventSource { + + private final TachographFileSessionRepository repository; + private final DriverTimelineEventBuilder eventBuilder; + + public TachographFileSessionUnifiedDriverEventSource( + TachographFileSessionRepository repository, + DriverTimelineEventBuilder eventBuilder + ) { + this.repository = repository; + this.eventBuilder = eventBuilder; + } + + @Override + public boolean supports(UnifiedDriverEventsRequest request) { + return request.sourceFamily() == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION; + } + + @Override + public List loadDriverEvents(UnifiedDriverEventsRequest request) { + TachographFileSession session = repository.find(request.sessionId()) + .orElseThrow(() -> new TachographFileSessionNotFoundException(request.sessionId())); + DriverExtractionSession driver = session.driversByKey().get(request.driverKey()); + if (driver == null) { + throw new DriverNotFoundInSessionException(request.sessionId(), request.driverKey()); + } + return eventBuilder.buildEvents(session, driver).stream() + .filter(event -> withinWindow(event.occurredAt(), request.occurredFrom(), request.occurredTo())) + .toList(); + } + + private boolean withinWindow( + OffsetDateTime occurredAt, + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo + ) { + if (occurredAt == null) { + return false; + } + if (occurredFrom != null && occurredAt.isBefore(occurredFrom)) { + return false; + } + return occurredTo == null || !occurredAt.isAfter(occurredTo); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverTimelineSource.java b/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverTimelineSource.java new file mode 100644 index 0000000..c105d79 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/TachographFileSessionUnifiedDriverTimelineSource.java @@ -0,0 +1,43 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.processing.model.UnifiedDriverTimelineRequest; +import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.service.DriverNotFoundInSessionException; +import at.procon.eventhub.tachographfilesession.service.EventBackedDriverTimelineBuilder; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionNotFoundException; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository; +import org.springframework.stereotype.Component; + +@Component +public class TachographFileSessionUnifiedDriverTimelineSource implements UnifiedDriverTimelineSource { + + private final TachographFileSessionRepository repository; + private final EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder; + + public TachographFileSessionUnifiedDriverTimelineSource( + TachographFileSessionRepository repository, + EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder + ) { + this.repository = repository; + this.eventBackedDriverTimelineBuilder = eventBackedDriverTimelineBuilder; + } + + @Override + public boolean supports(UnifiedDriverTimelineRequest request) { + return request.sourceFamily() == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION; + } + + @Override + public ResolvedDriverTimeline loadDriverTimeline(UnifiedDriverTimelineRequest request) { + TachographFileSession session = repository.find(request.sessionId()) + .orElseThrow(() -> new TachographFileSessionNotFoundException(request.sessionId())); + DriverExtractionSession driver = session.driversByKey().get(request.driverKey()); + if (driver == null) { + throw new DriverNotFoundInSessionException(request.sessionId(), request.driverKey()); + } + return eventBackedDriverTimelineBuilder.build(session, driver); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverEventSource.java b/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverEventSource.java new file mode 100644 index 0000000..6a5adbe --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverEventSource.java @@ -0,0 +1,12 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.processing.model.UnifiedDriverEventsRequest; +import java.util.List; + +public interface UnifiedDriverEventSource { + + boolean supports(UnifiedDriverEventsRequest request); + + List loadDriverEvents(UnifiedDriverEventsRequest request); +} diff --git a/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverEventSourceService.java b/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverEventSourceService.java new file mode 100644 index 0000000..891b988 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverEventSourceService.java @@ -0,0 +1,28 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.processing.model.UnifiedDriverEventsRequest; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class UnifiedDriverEventSourceService { + + private final List eventSources; + + public UnifiedDriverEventSourceService(List eventSources) { + this.eventSources = List.copyOf(eventSources); + } + + public List loadDriverEvents(UnifiedDriverEventsRequest request) { + return eventSources.stream() + .filter(source -> source.supports(request)) + .findFirst() + .orElseThrow(() -> unsupportedSource(request.sourceFamily().name())) + .loadDriverEvents(request); + } + + private IllegalArgumentException unsupportedSource(String sourceFamily) { + return new IllegalArgumentException("No unified driver event source is registered for source family " + sourceFamily + "."); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineService.java b/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineService.java new file mode 100644 index 0000000..b1166a9 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineService.java @@ -0,0 +1,28 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.processing.model.UnifiedDriverTimelineRequest; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class UnifiedDriverTimelineService { + + private final List timelineSources; + + public UnifiedDriverTimelineService(List timelineSources) { + this.timelineSources = List.copyOf(timelineSources); + } + + public ResolvedDriverTimeline loadDriverTimeline(UnifiedDriverTimelineRequest request) { + return timelineSources.stream() + .filter(source -> source.supports(request)) + .findFirst() + .orElseThrow(() -> unsupportedSource(request.sourceFamily().name())) + .loadDriverTimeline(request); + } + + private IllegalArgumentException unsupportedSource(String sourceFamily) { + return new IllegalArgumentException("No unified driver timeline source is registered for source family " + sourceFamily + "."); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineSource.java b/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineSource.java new file mode 100644 index 0000000..0c94754 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineSource.java @@ -0,0 +1,11 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.processing.model.UnifiedDriverTimelineRequest; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; + +public interface UnifiedDriverTimelineSource { + + boolean supports(UnifiedDriverTimelineRequest request); + + ResolvedDriverTimeline loadDriverTimeline(UnifiedDriverTimelineRequest request); +} diff --git a/src/main/java/at/procon/eventhub/processing/service/YellowFoxDbUnifiedDriverEventSource.java b/src/main/java/at/procon/eventhub/processing/service/YellowFoxDbUnifiedDriverEventSource.java new file mode 100644 index 0000000..685fb0e --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/service/YellowFoxDbUnifiedDriverEventSource.java @@ -0,0 +1,28 @@ +package at.procon.eventhub.processing.service; + +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.persistence.EventHubEventReadRepository; +import at.procon.eventhub.processing.model.UnifiedDriverEventsRequest; +import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class YellowFoxDbUnifiedDriverEventSource implements UnifiedDriverEventSource { + + private final EventHubEventReadRepository repository; + + public YellowFoxDbUnifiedDriverEventSource(EventHubEventReadRepository repository) { + this.repository = repository; + } + + @Override + public boolean supports(UnifiedDriverEventsRequest request) { + return request.sourceFamily() == UnifiedEventSourceFamily.YELLOWFOX_DB; + } + + @Override + public List loadDriverEvents(UnifiedDriverEventsRequest request) { + return repository.findEvents(request, "YELLOWFOX", List.of("TELEMATICS_PLATFORM")); + } +} diff --git a/src/test/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequestTest.java b/src/test/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequestTest.java new file mode 100644 index 0000000..8850ebf --- /dev/null +++ b/src/test/java/at/procon/eventhub/processing/model/UnifiedDriverEventsRequestTest.java @@ -0,0 +1,88 @@ +package at.procon.eventhub.processing.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.OffsetDateTime; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class UnifiedDriverEventsRequestTest { + + @Test + void buildsFileSessionRequest() { + UUID sessionId = UUID.randomUUID(); + + UnifiedDriverEventsRequest request = UnifiedDriverEventsRequest.forTachographFileSession( + sessionId, + " 12:123 ", + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-02T00:00:00Z") + ); + + assertThat(request.sourceFamily()).isEqualTo(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION); + assertThat(request.sessionId()).isEqualTo(sessionId); + assertThat(request.driverKey()).isEqualTo("12:123"); + assertThat(request.tenantKey()).isNull(); + } + + @Test + void buildsTachographDbDriverRequest() { + UnifiedDriverEventsRequest request = UnifiedDriverEventsRequest.forTachographDbDriver( + " default ", + " DRIVER:42 ", + "at", + " 123 ", + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-02T00:00:00Z") + ); + + assertThat(request.sourceFamily()).isEqualTo(UnifiedEventSourceFamily.TACHOGRAPH_DB); + assertThat(request.tenantKey()).isEqualTo("default"); + assertThat(request.driverSourceEntityId()).isEqualTo("DRIVER:42"); + assertThat(request.driverCardNation()).isEqualTo("AT"); + assertThat(request.driverCardNumber()).isEqualTo("123"); + assertThat(request.hasDriverSelector()).isTrue(); + assertThat(request.hasVehicleSelector()).isFalse(); + } + + @Test + void buildsYellowFoxVehicleRequest() { + UnifiedDriverEventsRequest request = UnifiedDriverEventsRequest.forYellowFoxDbVehicle( + "default", + "VEHICLE:99", + "wdb123", + "de", + "W-123AB", + null, + null + ); + + assertThat(request.sourceFamily()).isEqualTo(UnifiedEventSourceFamily.YELLOWFOX_DB); + assertThat(request.vehicleSourceEntityId()).isEqualTo("VEHICLE:99"); + assertThat(request.vin()).isEqualTo("WDB123"); + assertThat(request.registrationNation()).isEqualTo("DE"); + assertThat(request.registrationNumber()).isEqualTo("W-123AB"); + assertThat(request.hasVehicleSelector()).isTrue(); + } + + @Test + void rejectsDbRequestWithoutSubjectSelector() { + assertThatThrownBy(() -> new UnifiedDriverEventsRequest( + UnifiedEventSourceFamily.TACHOGRAPH_DB, + null, + null, + "default", + null, + null, + null, + null, + null, + null, + null, + null, + null + )).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("At least one driver or vehicle selector"); + } +} diff --git a/src/test/java/at/procon/eventhub/processing/service/UnifiedDriverEventSourceServiceTest.java b/src/test/java/at/procon/eventhub/processing/service/UnifiedDriverEventSourceServiceTest.java new file mode 100644 index 0000000..375d6a9 --- /dev/null +++ b/src/test/java/at/procon/eventhub/processing/service/UnifiedDriverEventSourceServiceTest.java @@ -0,0 +1,165 @@ +package at.procon.eventhub.processing.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.dto.EventDomain; +import at.procon.eventhub.dto.EventLifecycle; +import at.procon.eventhub.processing.model.UnifiedDriverEventsRequest; +import at.procon.eventhub.service.EventDetailsFactory; +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedDriver; +import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard; +import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent; +import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle; +import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration; +import at.procon.eventhub.tachographfilesession.model.ExtractionStats; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; +import at.procon.eventhub.tachographfilesession.service.DriverKeyFactory; +import at.procon.eventhub.tachographfilesession.service.DriverTimelineBuilder; +import at.procon.eventhub.tachographfilesession.service.InMemoryTachographFileSessionRepository; +import at.procon.eventhub.tachographfilesession.service.IntervalBackedDriverTimelineEventBuilder; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository; +import at.procon.eventhub.tachographfilesession.service.VehicleKeyFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class UnifiedDriverEventSourceServiceTest { + + @Test + void loadsNormalizedFileSessionEventsThroughUnifiedService() { + EventHubProperties properties = new EventHubProperties(); + TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); + DriverTimelineBuilder timelineBuilder = new DriverTimelineBuilder(); + UnifiedDriverEventSourceService service = new UnifiedDriverEventSourceService(List.of( + new TachographFileSessionUnifiedDriverEventSource( + repository, + new IntervalBackedDriverTimelineEventBuilder( + timelineBuilder, + new DriverKeyFactory(), + new VehicleKeyFactory(), + new EventDetailsFactory(new ObjectMapper()) + ) + ) + )); + + DriverExtractionSession driver = driver(); + TachographFileSession session = session(driver); + repository.save(session); + + List events = service.loadDriverEvents( + UnifiedDriverEventsRequest.forTachographFileSession( + session.sessionId(), + driver.driverKey(), + OffsetDateTime.parse("2026-05-01T08:30:00Z"), + OffsetDateTime.parse("2026-05-01T09:00:00Z") + ) + ); + + assertThat(events).hasSize(3); + assertThat(events).extracting(event -> event.occurredAt()) + .containsExactly( + OffsetDateTime.parse("2026-05-01T08:30:00Z"), + OffsetDateTime.parse("2026-05-01T08:45:00Z"), + OffsetDateTime.parse("2026-05-01T09:00:00Z") + ); + assertThat(events).extracting(event -> event.eventDomain()) + .containsExactly( + EventDomain.DRIVER_ACTIVITY, + EventDomain.POSITION, + EventDomain.DRIVER_ACTIVITY + ); + assertThat(events.get(0).lifecycle()).isEqualTo(EventLifecycle.START); + assertThat(events.get(2).lifecycle()).isEqualTo(EventLifecycle.END); + assertThat(events.get(1).eventDetails().type()).isEqualTo("POSITION"); + } + + private DriverExtractionSession driver() { + return new DriverExtractionSession( + "12:123", + new ExtractedDriver("12:123", "DRV:12:123", "Doe", "Jane", null, null, null, null, null), + new ExtractedDriverCard("CARD:12:123", "12", "123", null, null, null, null), + List.of(new ExtractedVehicleRegistration("12:REG-1", "VR:12:REG-1", "12", "REG-1")), + List.of(new ExtractedVehicle("VIN-1", "VIN:VIN-1", "VIN-1")), + List.of(new ExtractedCardVehicleUsageInterval( + "CVU-1", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + 100L, + 200L, + "12:REG-1", + "VIN-1", + "vu-1" + )), + List.of(new ExtractedCardActivityInterval( + "ACT-1", + OffsetDateTime.parse("2026-05-01T08:30:00Z"), + OffsetDateTime.parse("2026-05-01T09:00:00Z"), + "DRIVE", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-1", + "VIN-1", + "a" + )), + List.of(new ExtractedSupportEvent( + "SUP-1", + OffsetDateTime.parse("2026-05-01T08:45:00Z"), + "POSITION", + "POSITION_RECORDED", + "SNAPSHOT", + "DRIVER", + "12:REG-1", + "VIN-1", + null, + null, + null, + null, + null, + BigDecimal.valueOf(48.2082), + BigDecimal.valueOf(16.3738), + "AUTHENTIC", + 150L, + null, + null, + null, + "raw-path" + )), + List.of() + ); + } + + private TachographFileSession session(DriverExtractionSession driver) { + return new TachographFileSession( + UUID.randomUUID(), + new TachographFileSessionMetadata( + "default", + "legalrequirements-drivercard", + "sample", + "sample.ddd", + "a", + 2, + "42", + "b", + true, + null + ), + Map.of(driver.driverKey(), driver), + new ExtractionStats(1, 1, 1, 1, 1, 0), + List.of(), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + } +} diff --git a/src/test/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineServiceTest.java b/src/test/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineServiceTest.java new file mode 100644 index 0000000..b9e0e1c --- /dev/null +++ b/src/test/java/at/procon/eventhub/processing/service/UnifiedDriverTimelineServiceTest.java @@ -0,0 +1,150 @@ +package at.procon.eventhub.processing.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.processing.model.UnifiedDriverTimelineRequest; +import at.procon.eventhub.service.EventDetailsFactory; +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedDriver; +import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard; +import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent; +import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle; +import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration; +import at.procon.eventhub.tachographfilesession.model.ExtractionStats; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; +import at.procon.eventhub.tachographfilesession.service.DriverKeyFactory; +import at.procon.eventhub.tachographfilesession.service.EventBackedDriverTimelineBuilder; +import at.procon.eventhub.tachographfilesession.service.InMemoryTachographFileSessionRepository; +import at.procon.eventhub.tachographfilesession.service.IntervalBackedDriverTimelineEventBuilder; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository; +import at.procon.eventhub.tachographfilesession.service.VehicleKeyFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class UnifiedDriverTimelineServiceTest { + + @Test + void reconstructsTimelineThroughUnifiedService() { + EventHubProperties properties = new EventHubProperties(); + TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); + EventBackedDriverTimelineBuilder eventBackedBuilder = new EventBackedDriverTimelineBuilder( + new IntervalBackedDriverTimelineEventBuilder( + new at.procon.eventhub.tachographfilesession.service.DriverTimelineBuilder(), + new DriverKeyFactory(), + new VehicleKeyFactory(), + new EventDetailsFactory(new ObjectMapper()) + ) + ); + UnifiedDriverTimelineService service = new UnifiedDriverTimelineService(List.of( + new TachographFileSessionUnifiedDriverTimelineSource(repository, eventBackedBuilder) + )); + + DriverExtractionSession driver = driver(); + TachographFileSession session = session(driver); + repository.save(session); + + ResolvedDriverTimeline timeline = service.loadDriverTimeline( + UnifiedDriverTimelineRequest.forTachographFileSession(session.sessionId(), driver.driverKey()) + ); + + assertThat(timeline.sourceKind()).isEqualTo("DRIVER_CARD"); + assertThat(timeline.loadedFrom()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:00:00Z")); + assertThat(timeline.loadedTo()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z")); + assertThat(timeline.activityIntervals()).hasSize(1); + assertThat(timeline.vehicleUsageIntervals()).hasSize(1); + assertThat(timeline.supportEvents()).hasSize(1); + assertThat(timeline.activityIntervals().get(0).activityType()).isEqualTo("DRIVE"); + assertThat(timeline.vehicleUsageIntervals().get(0).registrationKey()).isEqualTo("12:REG-1"); + } + + private DriverExtractionSession driver() { + return new DriverExtractionSession( + "12:123", + new ExtractedDriver("12:123", "DRV:12:123", "Doe", "Jane", null, null, null, null, null), + new ExtractedDriverCard("CARD:12:123", "12", "123", null, null, null, null), + List.of(new ExtractedVehicleRegistration("12:REG-1", "VR:12:REG-1", "12", "REG-1")), + List.of(new ExtractedVehicle("VIN-1", "VIN:VIN-1", "VIN-1")), + List.of(new ExtractedCardVehicleUsageInterval( + "CVU-1", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + 100L, + 200L, + "12:REG-1", + "VIN-1", + "vu-1" + )), + List.of(new ExtractedCardActivityInterval( + "ACT-1", + OffsetDateTime.parse("2026-05-01T08:30:00Z"), + OffsetDateTime.parse("2026-05-01T09:00:00Z"), + "DRIVE", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-1", + "VIN-1", + "a" + )), + List.of(new ExtractedSupportEvent( + "SUP-1", + OffsetDateTime.parse("2026-05-01T08:45:00Z"), + "POSITION", + "POSITION_RECORDED", + "SNAPSHOT", + "DRIVER", + "12:REG-1", + "VIN-1", + null, + null, + null, + null, + null, + BigDecimal.valueOf(48.2082), + BigDecimal.valueOf(16.3738), + "AUTHENTIC", + 150L, + null, + null, + null, + "raw-path" + )), + List.of() + ); + } + + private TachographFileSession session(DriverExtractionSession driver) { + return new TachographFileSession( + UUID.randomUUID(), + new TachographFileSessionMetadata( + "default", + "legalrequirements-drivercard", + "sample", + "sample.ddd", + "a", + 2, + "42", + "b", + true, + null + ), + Map.of(driver.driverKey(), driver), + new ExtractionStats(1, 1, 1, 1, 1, 0), + List.of(), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + } +}