Add unified vehicle event sources

This commit is contained in:
trifonovt 2026-05-20 12:00:31 +02:00
parent 04d7bf513e
commit 8f5e51a5c1
9 changed files with 604 additions and 19 deletions

View File

@ -18,6 +18,7 @@ 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 at.procon.eventhub.processing.model.UnifiedVehicleEventsRequest;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -48,6 +49,57 @@ public class EventHubEventReadRepository {
UnifiedDriverEventsRequest request,
String providerKey,
List<String> sourceKinds
) {
return findEvents(
request.tenantKey(),
request.occurredFrom(),
request.occurredTo(),
request.driverSourceEntityId(),
request.driverCardNation(),
request.driverCardNumber(),
request.vehicleSourceEntityId(),
request.vin(),
request.registrationNation(),
request.registrationNumber(),
providerKey,
sourceKinds
);
}
public List<EventHubEventDto> findEventsByVehicle(
UnifiedVehicleEventsRequest request,
String providerKey,
List<String> sourceKinds
) {
return findEvents(
request.tenantKey(),
request.occurredFrom(),
request.occurredTo(),
null,
null,
null,
request.vehicleSourceEntityId(),
request.vin(),
request.registrationNation(),
request.registrationNumber(),
providerKey,
sourceKinds
);
}
private List<EventHubEventDto> findEvents(
String tenantKey,
OffsetDateTime occurredFrom,
OffsetDateTime occurredTo,
String driverSourceEntityId,
String driverCardNation,
String driverCardNumber,
String vehicleSourceEntityId,
String vin,
String registrationNation,
String registrationNumber,
String providerKey,
List<String> sourceKinds
) {
StringBuilder sql = new StringBuilder(
"""
@ -127,7 +179,7 @@ public class EventHubEventReadRepository {
);
List<Object> params = new ArrayList<>();
params.add(request.tenantKey());
params.add(tenantKey);
params.add(providerKey);
if (sourceKinds != null && !sourceKinds.isEmpty()) {
@ -141,40 +193,40 @@ public class EventHubEventReadRepository {
}
sql.append(")");
}
if (request.occurredFrom() != null) {
if (occurredFrom != null) {
sql.append(" and event.occurred_at >= ?");
params.add(request.occurredFrom());
params.add(occurredFrom);
}
if (request.occurredTo() != null) {
if (occurredTo != null) {
sql.append(" and event.occurred_at <= ?");
params.add(request.occurredTo());
params.add(occurredTo);
}
if (request.driverSourceEntityId() != null) {
if (driverSourceEntityId != null) {
sql.append(" and driver.source_driver_entity_id = ?");
params.add(request.driverSourceEntityId());
params.add(driverSourceEntityId);
}
if (request.driverCardNumber() != null) {
if (driverCardNumber != null) {
sql.append(" and driver_card.card_number = ?");
params.add(request.driverCardNumber());
if (request.driverCardNation() != null) {
params.add(driverCardNumber);
if (driverCardNation != null) {
sql.append(" and driver_card.nation = ?");
params.add(request.driverCardNation());
params.add(driverCardNation);
}
}
if (request.vehicleSourceEntityId() != null) {
if (vehicleSourceEntityId != null) {
sql.append(" and vehicle.source_vehicle_entity_id = ?");
params.add(request.vehicleSourceEntityId());
params.add(vehicleSourceEntityId);
}
if (request.vin() != null) {
if (vin != null) {
sql.append(" and vehicle.vin = ?");
params.add(request.vin());
params.add(vin);
}
if (request.registrationNumber() != null) {
if (registrationNumber != null) {
sql.append(" and registration.registration_number = ?");
params.add(request.registrationNumber());
if (request.registrationNation() != null) {
params.add(registrationNumber);
if (registrationNation != null) {
sql.append(" and registration.nation = ?");
params.add(request.registrationNation());
params.add(registrationNation);
}
}

View File

@ -0,0 +1,119 @@
package at.procon.eventhub.processing.model;
import java.time.OffsetDateTime;
import java.util.Objects;
import java.util.UUID;
public record UnifiedVehicleEventsRequest(
UnifiedEventSourceFamily sourceFamily,
UUID sessionId,
String tenantKey,
String vehicleSourceEntityId,
String vin,
String registrationNation,
String registrationNumber,
OffsetDateTime occurredFrom,
OffsetDateTime occurredTo
) {
public UnifiedVehicleEventsRequest {
Objects.requireNonNull(sourceFamily, "sourceFamily must not be null");
tenantKey = normalize(tenantKey);
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");
} else if (tenantKey == null) {
throw new IllegalArgumentException("tenantKey must not be blank");
}
if (!hasVehicleSelector(vehicleSourceEntityId, vin, registrationNumber)) {
throw new IllegalArgumentException("At least one vehicle selector must be provided.");
}
}
public static UnifiedVehicleEventsRequest forTachographFileSession(
UUID sessionId,
String vehicleSourceEntityId,
String vin,
String registrationNation,
String registrationNumber,
OffsetDateTime occurredFrom,
OffsetDateTime occurredTo
) {
return new UnifiedVehicleEventsRequest(
UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION,
sessionId,
null,
vehicleSourceEntityId,
vin,
registrationNation,
registrationNumber,
occurredFrom,
occurredTo
);
}
public static UnifiedVehicleEventsRequest forTachographDb(
String tenantKey,
String vehicleSourceEntityId,
String vin,
String registrationNation,
String registrationNumber,
OffsetDateTime occurredFrom,
OffsetDateTime occurredTo
) {
return new UnifiedVehicleEventsRequest(
UnifiedEventSourceFamily.TACHOGRAPH_DB,
null,
tenantKey,
vehicleSourceEntityId,
vin,
registrationNation,
registrationNumber,
occurredFrom,
occurredTo
);
}
public static UnifiedVehicleEventsRequest forYellowFoxDb(
String tenantKey,
String vehicleSourceEntityId,
String vin,
String registrationNation,
String registrationNumber,
OffsetDateTime occurredFrom,
OffsetDateTime occurredTo
) {
return new UnifiedVehicleEventsRequest(
UnifiedEventSourceFamily.YELLOWFOX_DB,
null,
tenantKey,
vehicleSourceEntityId,
vin,
registrationNation,
registrationNumber,
occurredFrom,
occurredTo
);
}
public boolean hasVehicleSelector() {
return hasVehicleSelector(vehicleSourceEntityId, vin, registrationNumber);
}
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();
}
}

View File

@ -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.UnifiedVehicleEventsRequest;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class TachographDbUnifiedVehicleEventSource implements UnifiedVehicleEventSource {
private static final List<String> SOURCE_KINDS = List.of("DRIVER_CARD", "VEHICLE_UNIT");
private final EventHubEventReadRepository repository;
public TachographDbUnifiedVehicleEventSource(EventHubEventReadRepository repository) {
this.repository = repository;
}
@Override
public boolean supports(UnifiedVehicleEventsRequest request) {
return request.sourceFamily() == UnifiedEventSourceFamily.TACHOGRAPH_DB;
}
@Override
public List<EventHubEventDto> loadVehicleEvents(UnifiedVehicleEventsRequest request) {
return repository.findEventsByVehicle(request, "TACHOGRAPH", SOURCE_KINDS);
}
}

View File

@ -0,0 +1,77 @@
package at.procon.eventhub.processing.service;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedVehicleEventsRequest;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
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 TachographFileSessionUnifiedVehicleEventSource implements UnifiedVehicleEventSource {
private final TachographFileSessionRepository repository;
private final DriverTimelineEventBuilder eventBuilder;
public TachographFileSessionUnifiedVehicleEventSource(
TachographFileSessionRepository repository,
DriverTimelineEventBuilder eventBuilder
) {
this.repository = repository;
this.eventBuilder = eventBuilder;
}
@Override
public boolean supports(UnifiedVehicleEventsRequest request) {
return request.sourceFamily() == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION;
}
@Override
public List<EventHubEventDto> loadVehicleEvents(UnifiedVehicleEventsRequest request) {
TachographFileSession session = repository.find(request.sessionId())
.orElseThrow(() -> new TachographFileSessionNotFoundException(request.sessionId()));
return session.driversByKey().values().stream()
.flatMap(driver -> eventBuilder.buildEvents(session, driver).stream())
.filter(event -> matchesVehicle(event.vehicleRef(), request))
.filter(event -> withinWindow(event.occurredAt(), request.occurredFrom(), request.occurredTo()))
.distinct()
.toList();
}
private boolean matchesVehicle(VehicleRefDto vehicleRef, UnifiedVehicleEventsRequest request) {
if (vehicleRef == null || !vehicleRef.hasAnyReference()) {
return false;
}
if (request.vehicleSourceEntityId() != null
&& request.vehicleSourceEntityId().equals(vehicleRef.sourceVehicleEntityId())) {
return true;
}
if (request.vin() != null && request.vin().equals(vehicleRef.vin())) {
return true;
}
return request.registrationNumber() != null
&& vehicleRef.vehicleRegistration() != null
&& request.registrationNumber().equals(vehicleRef.vehicleRegistration().number())
&& (request.registrationNation() == null
|| request.registrationNation().equals(vehicleRef.vehicleRegistration().nation()));
}
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);
}
}

View File

@ -0,0 +1,12 @@
package at.procon.eventhub.processing.service;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.processing.model.UnifiedVehicleEventsRequest;
import java.util.List;
public interface UnifiedVehicleEventSource {
boolean supports(UnifiedVehicleEventsRequest request);
List<EventHubEventDto> loadVehicleEvents(UnifiedVehicleEventsRequest request);
}

View File

@ -0,0 +1,28 @@
package at.procon.eventhub.processing.service;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.processing.model.UnifiedVehicleEventsRequest;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class UnifiedVehicleEventSourceService {
private final List<UnifiedVehicleEventSource> eventSources;
public UnifiedVehicleEventSourceService(List<UnifiedVehicleEventSource> eventSources) {
this.eventSources = List.copyOf(eventSources);
}
public List<EventHubEventDto> loadVehicleEvents(UnifiedVehicleEventsRequest request) {
return eventSources.stream()
.filter(source -> source.supports(request))
.findFirst()
.orElseThrow(() -> unsupportedSource(request.sourceFamily().name()))
.loadVehicleEvents(request);
}
private IllegalArgumentException unsupportedSource(String sourceFamily) {
return new IllegalArgumentException("No unified vehicle event source is registered for source family " + sourceFamily + ".");
}
}

View File

@ -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.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedVehicleEventsRequest;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class YellowFoxDbUnifiedVehicleEventSource implements UnifiedVehicleEventSource {
private final EventHubEventReadRepository repository;
public YellowFoxDbUnifiedVehicleEventSource(EventHubEventReadRepository repository) {
this.repository = repository;
}
@Override
public boolean supports(UnifiedVehicleEventsRequest request) {
return request.sourceFamily() == UnifiedEventSourceFamily.YELLOWFOX_DB;
}
@Override
public List<EventHubEventDto> loadVehicleEvents(UnifiedVehicleEventsRequest request) {
return repository.findEventsByVehicle(request, "YELLOWFOX", List.of("TELEMATICS_PLATFORM"));
}
}

View File

@ -0,0 +1,69 @@
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 UnifiedVehicleEventsRequestTest {
@Test
void buildsFileSessionVehicleRequest() {
UUID sessionId = UUID.randomUUID();
UnifiedVehicleEventsRequest request = UnifiedVehicleEventsRequest.forTachographFileSession(
sessionId,
" VEHICLE:1 ",
"wdb123",
"at",
"W-123AB",
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.vehicleSourceEntityId()).isEqualTo("VEHICLE:1");
assertThat(request.vin()).isEqualTo("WDB123");
assertThat(request.registrationNation()).isEqualTo("AT");
assertThat(request.registrationNumber()).isEqualTo("W-123AB");
}
@Test
void buildsDbVehicleRequest() {
UnifiedVehicleEventsRequest request = UnifiedVehicleEventsRequest.forTachographDb(
" default ",
null,
"vin-42",
"de",
"B-123",
null,
null
);
assertThat(request.sourceFamily()).isEqualTo(UnifiedEventSourceFamily.TACHOGRAPH_DB);
assertThat(request.tenantKey()).isEqualTo("default");
assertThat(request.vin()).isEqualTo("VIN-42");
assertThat(request.registrationNation()).isEqualTo("DE");
assertThat(request.registrationNumber()).isEqualTo("B-123");
assertThat(request.hasVehicleSelector()).isTrue();
}
@Test
void rejectsRequestWithoutVehicleSelector() {
assertThatThrownBy(() -> new UnifiedVehicleEventsRequest(
UnifiedEventSourceFamily.YELLOWFOX_DB,
null,
"default",
null,
null,
null,
null,
null,
null
)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("At least one vehicle selector");
}
}

View File

@ -0,0 +1,170 @@
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.UnifiedVehicleEventsRequest;
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 UnifiedVehicleEventSourceServiceTest {
@Test
void loadsNormalizedFileSessionVehicleEventsThroughUnifiedService() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
DriverTimelineBuilder timelineBuilder = new DriverTimelineBuilder();
UnifiedVehicleEventSourceService service = new UnifiedVehicleEventSourceService(List.of(
new TachographFileSessionUnifiedVehicleEventSource(
repository,
new IntervalBackedDriverTimelineEventBuilder(
timelineBuilder,
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
)
)
));
DriverExtractionSession driver = driver();
TachographFileSession session = session(driver);
repository.save(session);
List<at.procon.eventhub.dto.EventHubEventDto> events = service.loadVehicleEvents(
UnifiedVehicleEventsRequest.forTachographFileSession(
session.sessionId(),
null,
"VIN-1",
null,
null,
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T09:00:00Z")
)
);
assertThat(events).hasSize(4);
assertThat(events).extracting(event -> event.occurredAt())
.containsExactly(
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
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_CARD,
EventDomain.DRIVER_ACTIVITY,
EventDomain.POSITION,
EventDomain.DRIVER_ACTIVITY
);
assertThat(events.get(0).lifecycle()).isEqualTo(EventLifecycle.INSERT);
assertThat(events.get(1).lifecycle()).isEqualTo(EventLifecycle.START);
assertThat(events.get(3).lifecycle()).isEqualTo(EventLifecycle.END);
}
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)
);
}
}