Compare commits

..

4 Commits

Author SHA1 Message Date
trifonovt 04d7bf513e Add unified event processing sources 2026-05-20 10:57:36 +02:00
trifonovt dd0ccae290 Add and optimize tachograph session support events 2026-05-20 10:20:04 +02:00
trifonovt 2ded38a28a Add event-backed tachograph timeline mode 2026-05-20 09:03:35 +02:00
trifonovt 9ef8bfc412 Add reusable tachograph projections and event adapter 2026-05-19 16:21:26 +02:00
36 changed files with 4561 additions and 27 deletions

View File

@ -356,12 +356,23 @@ public class EventHubProperties {
}
public static class Processing {
private TimelineInputMode timelineInputMode = TimelineInputMode.INTERVALS;
private int operatingSplitIdleHours = 7;
private int significantDrivingMinutes = 3;
private int minimumRestPeriodMinutes = 720;
private int mergeGapSeconds = 0;
private int gapDetectionToleranceSeconds = 0;
public TimelineInputMode getTimelineInputMode() {
return timelineInputMode;
}
public void setTimelineInputMode(TimelineInputMode timelineInputMode) {
if (timelineInputMode != null) {
this.timelineInputMode = timelineInputMode;
}
}
public int getOperatingSplitIdleHours() {
return operatingSplitIdleHours;
}
@ -403,6 +414,11 @@ public class EventHubProperties {
}
}
public enum TimelineInputMode {
INTERVALS,
EVENTS
}
public static class LegalRequirements {
private static final String DEFAULT_BASE_URL = "https://legalrequirements.services.bytebar.eu/ODataV4/LR";

View File

@ -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<EventHubEventDto> findEvents(
UnifiedDriverEventsRequest request,
String providerKey,
List<String> 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<Object> 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 extends Enum<T>> T enumValue(Class<T> 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;
}
}
}

View File

@ -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();
}
}

View File

@ -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
);
}
}

View File

@ -0,0 +1,7 @@
package at.procon.eventhub.processing.model;
public enum UnifiedEventSourceFamily {
TACHOGRAPH_FILE_SESSION,
TACHOGRAPH_DB,
YELLOWFOX_DB
}

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.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<String> 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<EventHubEventDto> loadDriverEvents(UnifiedDriverEventsRequest request) {
return repository.findEvents(request, "TACHOGRAPH", SOURCE_KINDS);
}
}

View File

@ -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<EventHubEventDto> 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);
}
}

View File

@ -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);
}
}

View File

@ -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<EventHubEventDto> loadDriverEvents(UnifiedDriverEventsRequest 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.UnifiedDriverEventsRequest;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class UnifiedDriverEventSourceService {
private final List<UnifiedDriverEventSource> eventSources;
public UnifiedDriverEventSourceService(List<UnifiedDriverEventSource> eventSources) {
this.eventSources = List.copyOf(eventSources);
}
public List<EventHubEventDto> 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 + ".");
}
}

View File

@ -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<UnifiedDriverTimelineSource> timelineSources;
public UnifiedDriverTimelineService(List<UnifiedDriverTimelineSource> 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 + ".");
}
}

View File

@ -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);
}

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.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<EventHubEventDto> loadDriverEvents(UnifiedDriverEventsRequest request) {
return repository.findEvents(request, "YELLOWFOX", List.of("TELEMATICS_PLATFORM"));
}
}

View File

@ -8,16 +8,22 @@ public record ExtractedSupportEvent(
OffsetDateTime occurredAt,
String eventDomain,
String eventType,
String eventLifecycle,
String slot,
String registrationKey,
String vehicleKey,
String country,
String region,
String countryFrom,
String countryTo,
String operation,
BigDecimal latitude,
BigDecimal longitude,
String authenticationStatus,
Long odometerKm,
String code,
BigDecimal avgSpeedKmh,
BigDecimal maxSpeedKmh,
String rawRecordPath
) {
}

View File

@ -0,0 +1,12 @@
package at.procon.eventhub.tachographfilesession.model;
import java.util.List;
public record TachographEsperDrivingDerivedProjectionBundle(
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals,
List<TachographEsperDrivingInterruptionIntervalEvent> dailyWeeklyRestCandidateIntervals,
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals,
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals,
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals
) {
}

View File

@ -0,0 +1,35 @@
package at.procon.eventhub.tachographfilesession.model;
import at.procon.eventhub.dto.EventHubEventDto;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public record TachographTimelineEventBundle(
List<EventHubEventDto> activityEvents,
List<EventHubEventDto> vehicleUsageEvents,
List<EventHubEventDto> supportEvents
) {
public TachographTimelineEventBundle {
activityEvents = copy(activityEvents);
vehicleUsageEvents = copy(vehicleUsageEvents);
supportEvents = copy(supportEvents);
}
public List<EventHubEventDto> allEvents() {
List<EventHubEventDto> result = new ArrayList<>(activityEvents.size() + vehicleUsageEvents.size() + supportEvents.size());
result.addAll(activityEvents);
result.addAll(vehicleUsageEvents);
result.addAll(supportEvents);
result.sort(Comparator.comparing(EventHubEventDto::occurredAt)
.thenComparing(event -> event.eventDomain().name())
.thenComparing(event -> event.eventType().name())
.thenComparing(event -> event.lifecycle().name())
.thenComparing(EventHubEventDto::externalSourceEventId));
return List.copyOf(result);
}
private static List<EventHubEventDto> copy(List<EventHubEventDto> events) {
return events == null ? List.of() : List.copyOf(events);
}
}

View File

@ -5,12 +5,14 @@ import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInter
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.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
@ -71,6 +73,7 @@ public class DriverCardXmlExtractionService {
extractVehicleUsageIntervals(document, registrationsByKey, vehiclesByKey, warnings);
List<ExtractedCardActivityInterval> activityIntervals =
assignVehicleCoverage(extractActivityIntervals(document, warnings), vehicleUsageIntervals);
List<ExtractedSupportEvent> supportEvents = extractSupportEvents(document, vehicleUsageIntervals, warnings);
DriverExtractionSession driverSession = new DriverExtractionSession(
driverKey,
@ -80,7 +83,7 @@ public class DriverCardXmlExtractionService {
List.copyOf(vehiclesByKey.values()),
List.copyOf(vehicleUsageIntervals),
List.copyOf(activityIntervals),
List.of(),
List.copyOf(supportEvents),
List.copyOf(warnings)
);
Map<String, DriverExtractionSession> driversByKey = Map.of(driverKey, driverSession);
@ -302,6 +305,284 @@ public class DriverCardXmlExtractionService {
return result;
}
private List<ExtractedSupportEvent> extractSupportEvents(
Document document,
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals,
List<ExtractionWarning> warnings
) {
VehicleUsageLookup vehicleUsageLookup = new VehicleUsageLookup(vehicleUsageIntervals);
List<ExtractedSupportEvent> supportEvents = new ArrayList<>();
Element root = document.getDocumentElement();
extractCardPlaceSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
extractCardGnssSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
extractCardSpecificConditionSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
extractCardBorderCrossingSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
extractCardLoadUnloadSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
supportEvents.sort(Comparator.comparing(ExtractedSupportEvent::occurredAt)
.thenComparing(ExtractedSupportEvent::eventDomain, Comparator.nullsLast(String::compareTo))
.thenComparing(ExtractedSupportEvent::eventId, Comparator.nullsLast(String::compareTo)));
return List.copyOf(supportEvents);
}
private void extractCardPlaceSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "Places");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element cardPlaceDailyWorkPeriod = child(sections.get(sectionIndex), "cardPlaceDailyWorkPeriod");
List<Element> records = children(cardPlaceDailyWorkPeriod, "placeRecords");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/Places[" + (sectionIndex + 1) + "]/cardPlaceDailyWorkPeriod/placeRecords[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(childText(record, "entryTime"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_PLACE_MISSING_TIME", "Driver-card place record is missing entryTime.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
Element gnss = child(record, "entryGnssPlaceRecord");
Element geoCoordinates = child(gnss, "geoCoordinates");
BigDecimal latitude = geoCoordinate(geoCoordinates, "latitude", true);
BigDecimal longitude = geoCoordinate(geoCoordinates, "longitude", false);
String authenticationStatus = normalizeToken(childText(gnss, "authenticationStatus"));
String entryType = childText(record, "entryTypeDailyWorkPeriod");
supportEvents.add(new ExtractedSupportEvent(
"CARDPLACE-" + (i + 1),
occurredAt,
"PLACE",
mapPlaceEntryType(entryType),
null,
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
childText(record, "dailyWorkPeriodCountry"),
childText(record, "dailyWorkPeriodRegion"),
null,
null,
null,
latitude,
longitude,
authenticationStatus,
longValue(childText(record, "vehicleOdometerValue")),
normalizeToken(entryType),
null,
null,
path
));
}
}
}
private void extractCardGnssSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "GnssPlaces");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element gnssAccumulatedDriving = child(sections.get(sectionIndex), "gnssAccumulatedDriving");
List<Element> records = children(gnssAccumulatedDriving, "gnssAccumulatedDrivingRecord");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/GnssPlaces[" + (sectionIndex + 1) + "]/gnssAccumulatedDriving/gnssAccumulatedDrivingRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(childText(record, "timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_GNSS_MISSING_TIME", "Driver-card GNSS record is missing timeStamp.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
Element gnss = child(record, "gnssPlaceRecord");
Element geoCoordinates = child(gnss, "geoCoordinates");
BigDecimal latitude = geoCoordinate(geoCoordinates, "latitude", true);
BigDecimal longitude = geoCoordinate(geoCoordinates, "longitude", false);
String authenticationStatus = normalizeToken(childText(gnss, "authenticationStatus"));
supportEvents.add(new ExtractedSupportEvent(
"CARDGNSS-" + (i + 1),
occurredAt,
"POSITION",
"POSITION_RECORDED",
"SNAPSHOT",
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
null,
null,
null,
null,
null,
latitude,
longitude,
authenticationStatus,
longValue(childText(record, "vehicleOdometerValue")),
null,
null,
null,
path
));
}
}
}
private void extractCardSpecificConditionSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "SpecificConditions");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element specificConditionData = child(sections.get(sectionIndex), "specificConditionData");
List<Element> records = children(specificConditionData, "specificConditionRecord");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/SpecificConditions[" + (sectionIndex + 1) + "]/specificConditionData/specificConditionRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(childText(record, "entryTime"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_SPECIFIC_CONDITION_MISSING_TIME", "Driver-card specific-condition record is missing entryTime.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
String conditionCode = normalizeToken(childText(record, "specificConditionType"));
String[] specificCondition = mapSpecificCondition(conditionCode);
supportEvents.add(new ExtractedSupportEvent(
"CARDSC-" + (i + 1),
occurredAt,
"SPECIFIC_CONDITION",
specificCondition[0],
specificCondition[1],
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
null,
null,
null,
null,
null,
null,
null,
null,
null,
conditionCode,
null,
null,
path
));
}
}
}
private void extractCardBorderCrossingSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "BorderCrossings");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element cardBorderCrossings = child(sections.get(sectionIndex), "cardBorderCrossings");
List<Element> records = children(cardBorderCrossings, "cardBorderCrossingRecord");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/BorderCrossings[" + (sectionIndex + 1) + "]/cardBorderCrossings/cardBorderCrossingRecord[" + (i + 1) + "]";
Element gnss = child(record, "gnssPlaceAuthRecord");
OffsetDateTime occurredAt = offsetDateTime(childText(gnss, "timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_BORDER_CROSSING_MISSING_TIME", "Driver-card border-crossing record is missing gnssPlaceAuthRecord/timeStamp.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
Element geoCoordinates = child(gnss, "geoCoordinates");
BigDecimal latitude = geoCoordinate(geoCoordinates, "latitude", true);
BigDecimal longitude = geoCoordinate(geoCoordinates, "longitude", false);
String authenticationStatus = normalizeToken(childText(gnss, "authenticationStatus"));
supportEvents.add(new ExtractedSupportEvent(
"CARDBORDER-" + (i + 1),
occurredAt,
"BORDER_CROSSING",
"BORDER_OUTBOUND",
"OUTBOUND",
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
null,
null,
childText(record, "countryLeft"),
childText(record, "countryEntered"),
null,
latitude,
longitude,
authenticationStatus,
longValue(childText(record, "vehicleOdometerValue")),
null,
null,
null,
path
));
}
}
}
private void extractCardLoadUnloadSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "LoadUnloadOperations");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element cardLoadUnloadOperations = child(sections.get(sectionIndex), "cardLoadUnloadOperations");
List<Element> records = children(cardLoadUnloadOperations, "cardLoadUnloadRecord");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/LoadUnloadOperations[" + (sectionIndex + 1) + "]/cardLoadUnloadOperations/cardLoadUnloadRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(childText(record, "timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_LOAD_UNLOAD_MISSING_TIME", "Driver-card load/unload record is missing timeStamp.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
Element gnss = child(record, "gnssPlaceAuthRecord");
Element geoCoordinates = child(gnss, "geoCoordinates");
BigDecimal latitude = geoCoordinate(geoCoordinates, "latitude", true);
BigDecimal longitude = geoCoordinate(geoCoordinates, "longitude", false);
String authenticationStatus = normalizeToken(childText(gnss, "authenticationStatus"));
String operation = mapOperation(childText(record, "operationType"));
supportEvents.add(new ExtractedSupportEvent(
"CARDLOAD-" + (i + 1),
occurredAt,
"LOAD_UNLOAD",
operation,
"SNAPSHOT",
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
null,
null,
null,
null,
operation,
latitude,
longitude,
authenticationStatus,
longValue(childText(record, "vehicleOdometerValue")),
normalizeToken(childText(record, "operationType")),
null,
null,
path
));
}
}
}
private List<ExtractedCardActivityInterval> splitByVehicleCoverage(
ExtractedCardActivityInterval interval,
List<ExtractedCardVehicleUsageInterval> overlappingUsages
@ -366,6 +647,26 @@ public class DriverCardXmlExtractionService {
return usage.to().plusSeconds(1);
}
private BigDecimal geoCoordinate(Element geoCoordinates, String component, boolean latitude) {
String value = childText(geoCoordinates, component);
if (value == null || value.isBlank()) {
return null;
}
BigDecimal raw = new BigDecimal(value);
BigDecimal abs = raw.abs();
BigDecimal degreeThreshold = BigDecimal.valueOf(latitude ? 90D : 180D);
if (abs.compareTo(degreeThreshold) <= 0) {
return raw;
}
BigDecimal sign = raw.signum() < 0 ? BigDecimal.valueOf(-1L) : BigDecimal.ONE;
BigDecimal absolute = raw.abs();
BigDecimal[] degreeAndRemainder = absolute.divideAndRemainder(BigDecimal.valueOf(1000L));
BigDecimal degrees = degreeAndRemainder[0];
BigDecimal minutes = degreeAndRemainder[1].divide(BigDecimal.TEN);
BigDecimal decimalDegrees = degrees.add(minutes.divide(BigDecimal.valueOf(60L), 8, java.math.RoundingMode.HALF_UP));
return decimalDegrees.multiply(sign);
}
private Element child(Element parent, String name) {
if (parent == null) {
return null;
@ -456,6 +757,50 @@ public class DriverCardXmlExtractionService {
return value.trim().toUpperCase().replace('-', '_').replace(' ', '_');
}
private String mapPlaceEntryType(String entryType) {
String normalized = normalizeToken(entryType);
if (normalized == null) {
return "DAILY_WORK_PERIOD_PLACE";
}
return switch (normalized) {
case "0" -> "BEGIN_DAILY_WORK_PERIOD";
case "1" -> "END_DAILY_WORK_PERIOD";
case "2" -> "BEGIN_MANUAL_DAILY_WORK_PERIOD";
case "3" -> "END_MANUAL_DAILY_WORK_PERIOD";
case "4" -> "BEGIN_ASSUMED_DAILY_WORK_PERIOD";
case "5" -> "END_ASSUMED_DAILY_WORK_PERIOD";
case "6" -> "BEGIN_GNSS_DAILY_WORK_PERIOD";
case "7" -> "END_GNSS_DAILY_WORK_PERIOD";
default -> "DAILY_WORK_PERIOD_PLACE";
};
}
private String[] mapSpecificCondition(String conditionCode) {
String normalized = normalizeToken(conditionCode);
if (normalized == null) {
return new String[]{"UNKNOWN_EVENT", "SNAPSHOT"};
}
return switch (normalized) {
case "1" -> new String[]{"OUT", "BEGIN"};
case "2" -> new String[]{"OUT", "END"};
case "3" -> new String[]{"FERRY_TRAIN", "BEGIN"};
case "4" -> new String[]{"FERRY_TRAIN", "END"};
default -> new String[]{"UNKNOWN_EVENT", "SNAPSHOT"};
};
}
private String mapOperation(String operationType) {
String normalized = normalizeToken(operationType);
if (normalized == null) {
return "LOAD_UNLOAD";
}
return switch (normalized) {
case "1", "LOAD" -> "LOAD";
case "2", "UNLOAD" -> "UNLOAD";
default -> "LOAD_UNLOAD";
};
}
private record ActivityChange(
OffsetDateTime from,
String activityType,
@ -484,4 +829,42 @@ public class DriverCardXmlExtractionService {
}
return builder.toString();
}
private final class VehicleUsageLookup {
private final List<ExtractedCardVehicleUsageInterval> intervals;
private VehicleUsageLookup(List<ExtractedCardVehicleUsageInterval> intervals) {
this.intervals = intervals == null ? List.of() : intervals;
}
private ExtractedCardVehicleUsageInterval resolve(OffsetDateTime occurredAt) {
if (occurredAt == null || intervals.isEmpty()) {
return null;
}
int low = 0;
int high = intervals.size() - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
ExtractedCardVehicleUsageInterval interval = intervals.get(mid);
if (interval.from().isAfter(occurredAt)) {
high = mid - 1;
continue;
}
if (covers(interval, occurredAt)) {
return interval;
}
low = mid + 1;
}
for (int i = Math.min(high, intervals.size() - 1); i >= 0; i--) {
ExtractedCardVehicleUsageInterval interval = intervals.get(i);
if (covers(interval, occurredAt)) {
return interval;
}
if (interval.from().isBefore(occurredAt) && usageEndExclusive(interval, null).isBefore(occurredAt)) {
break;
}
}
return null;
}
}
}

View File

@ -0,0 +1,37 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.dto.EventHubEventDto;
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.model.TachographTimelineEventBundle;
import java.util.List;
public interface DriverTimelineEventBuilder {
TachographTimelineEventBundle buildEventBundle(
TachographFileSession session,
DriverExtractionSession driverSession
);
TachographTimelineEventBundle buildEventBundle(
TachographFileSession session,
DriverExtractionSession driverSession,
ResolvedDriverTimeline timeline
);
default List<EventHubEventDto> buildEvents(
TachographFileSession session,
DriverExtractionSession driverSession
) {
return buildEventBundle(session, driverSession).allEvents();
}
default List<EventHubEventDto> buildEvents(
TachographFileSession session,
DriverExtractionSession driverSession,
ResolvedDriverTimeline timeline
) {
return buildEventBundle(session, driverSession, timeline).allEvents();
}
}

View File

@ -0,0 +1,442 @@
package at.procon.eventhub.tachographfilesession.service;
import com.espertech.esper.common.client.EPCompiled;
import com.espertech.esper.common.client.EventBean;
import com.espertech.esper.common.client.configuration.Configuration;
import com.espertech.esper.compiler.client.CompilerArguments;
import com.espertech.esper.compiler.client.EPCompileException;
import com.espertech.esper.compiler.client.EPCompilerProvider;
import com.espertech.esper.runtime.client.EPDeployException;
import com.espertech.esper.runtime.client.EPDeployment;
import com.espertech.esper.runtime.client.EPRuntime;
import com.espertech.esper.runtime.client.EPRuntimeProvider;
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingDerivedProjectionBundle;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
@Component
public class DriverTimelineReusableProjectionBuilder {
private static final AtomicLong RUNTIME_COUNTER = new AtomicLong();
private static final String DRIVING_DERIVED_PROJECTION_BUNDLE_EPL_TEMPLATE =
loadResource("esper/tachograph-driving-derived-projection-bundle.epl");
private final DriverTimelineBuilder driverTimelineBuilder;
public DriverTimelineReusableProjectionBuilder(DriverTimelineBuilder driverTimelineBuilder) {
this.driverTimelineBuilder = driverTimelineBuilder;
}
public TachographEsperDrivingDerivedProjectionBundle buildEsperDrivingDerivedProjectionBundle(
TachographFileSession session,
DriverExtractionSession driverSession,
int significantDrivingMinutes,
int minimumRestPeriodMinutes
) {
ResolvedDriverTimeline timeline = driverTimelineBuilder.build(session, driverSession);
return buildEsperDrivingDerivedProjectionBundle(
session.sessionId(),
driverSession.driverKey(),
timeline,
significantDrivingMinutes,
minimumRestPeriodMinutes
);
}
public TachographEsperDrivingDerivedProjectionBundle buildEsperDrivingDerivedProjectionBundle(
UUID sessionId,
String driverKey,
ResolvedDriverTimeline timeline,
int significantDrivingMinutes,
int minimumRestPeriodMinutes
) {
if (timeline == null) {
return emptyBundle();
}
return buildEsperDrivingDerivedProjectionBundle(
sessionId,
driverKey,
timeline.activityIntervals(),
timeline.vehicleUsageIntervals(),
significantDrivingMinutes,
minimumRestPeriodMinutes
);
}
private TachographEsperDrivingDerivedProjectionBundle buildEsperDrivingDerivedProjectionBundle(
UUID sessionId,
String driverKey,
List<ResolvedActivityInterval> activityIntervals,
List<ResolvedVehicleUsageInterval> vehicleUsageIntervals,
int significantDrivingMinutes,
int minimumRestPeriodMinutes
) {
if ((activityIntervals == null || activityIntervals.isEmpty())
&& (vehicleUsageIntervals == null || vehicleUsageIntervals.isEmpty())) {
return emptyBundle();
}
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals = new ArrayList<>();
List<TachographEsperDrivingInterruptionIntervalEvent> dailyWeeklyRestCandidateIntervals = new ArrayList<>();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals = new ArrayList<>();
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals = new ArrayList<>();
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals = new ArrayList<>();
executeWithRuntime(
configuration -> {
configuration.getCommon().addEventType(
"TachographActivityIntervalInputEvent",
activityIntervalInputDefinition()
);
configuration.getCommon().addEventType(
"TachographVehicleUsageIntervalInputEvent",
vehicleUsageIntervalInputDefinition()
);
},
renderDrivingDerivedProjectionBundleEpl(significantDrivingMinutes, minimumRestPeriodMinutes),
Map.of(
"drivingInterruptionIntervals", newData -> collectDrivingInterruptionIntervalEvents(newData, drivingInterruptionIntervals),
"dailyWeeklyRestCandidateIntervals", newData -> collectDrivingInterruptionIntervalEvents(newData, dailyWeeklyRestCandidateIntervals),
"drivingInterruptionVehicleChangeIntervals", newData -> collectDrivingInterruptionIntervalEvents(newData, drivingInterruptionVehicleChangeIntervals),
"vuCardAbsentIntervals", newData -> collectVuCardAbsentIntervalEvents(newData, vuCardAbsentIntervals),
"potentialHomeOvernightStayIntervals", newData -> collectPotentialHomeOvernightStayIntervalEvents(newData, potentialHomeOvernightStayIntervals)
),
runtime -> {
if (vehicleUsageIntervals != null) {
for (ResolvedVehicleUsageInterval interval : vehicleUsageIntervals) {
runtime.getEventService().sendEventMap(
toVehicleUsageIntervalInputMap(interval),
"TachographVehicleUsageIntervalInputEvent"
);
}
}
if (activityIntervals != null) {
for (ResolvedActivityInterval interval : activityIntervals) {
runtime.getEventService().sendEventMap(
toActivityIntervalInputMap(sessionId, driverKey, interval),
"TachographActivityIntervalInputEvent"
);
}
}
}
);
return new TachographEsperDrivingDerivedProjectionBundle(
sortDrivingInterruptionIntervals(drivingInterruptionIntervals),
sortDrivingInterruptionIntervals(dailyWeeklyRestCandidateIntervals),
sortDrivingInterruptionIntervals(drivingInterruptionVehicleChangeIntervals),
sortVuCardAbsentIntervals(vuCardAbsentIntervals),
sortPotentialHomeOvernightStayIntervals(potentialHomeOvernightStayIntervals)
);
}
private TachographEsperDrivingDerivedProjectionBundle emptyBundle() {
return new TachographEsperDrivingDerivedProjectionBundle(
List.of(),
List.of(),
List.of(),
List.of(),
List.of()
);
}
private void executeWithRuntime(
Consumer<Configuration> configurationSetup,
String epl,
Map<String, Consumer<EventBean[]>> listeners,
Consumer<EPRuntime> sender
) {
EPRuntime runtime = null;
try {
Configuration configuration = new Configuration();
configurationSetup.accept(configuration);
String runtimeUri = "eventhub-tachograph-reusable-projection-" + RUNTIME_COUNTER.incrementAndGet();
runtime = EPRuntimeProvider.getRuntime(runtimeUri, configuration);
CompilerArguments arguments = new CompilerArguments(configuration);
EPCompiled compiled = EPCompilerProvider.getCompiler().compile(epl, arguments);
EPDeployment deployment = runtime.getDeploymentService().deploy(compiled);
for (Map.Entry<String, Consumer<EventBean[]>> entry : listeners.entrySet()) {
runtime.getDeploymentService()
.getStatement(deployment.getDeploymentId(), entry.getKey())
.addListener((newData, oldData, statement, rt) -> entry.getValue().accept(newData));
}
sender.accept(runtime);
} catch (EPCompileException | EPDeployException e) {
throw new IllegalStateException("Cannot compile/deploy reusable tachograph projection EPL bundle", e);
} finally {
if (runtime != null) {
runtime.destroy();
}
}
}
private Map<String, Object> activityIntervalInputDefinition() {
Map<String, Object> definition = new LinkedHashMap<>();
definition.put("sessionId", UUID.class);
definition.put("driverKey", String.class);
definition.put("intervalId", String.class);
definition.put("activityType", String.class);
definition.put("cardSlot", String.class);
definition.put("cardStatus", String.class);
definition.put("drivingStatus", String.class);
definition.put("registrationKey", String.class);
definition.put("vehicleKey", String.class);
definition.put("sourceKind", String.class);
definition.put("firstSourceIntervalId", String.class);
definition.put("lastSourceIntervalId", String.class);
definition.put("startedAt", OffsetDateTime.class);
definition.put("endedAt", OffsetDateTime.class);
definition.put("startedAtEpochSecond", long.class);
definition.put("endedAtEpochSecond", long.class);
definition.put("durationSeconds", long.class);
definition.put("sourceIntervalIds", java.util.List.class);
definition.put("synthetic", boolean.class);
definition.put("clippedToRequestedPeriod", boolean.class);
definition.put("level", String.class);
return definition;
}
private Map<String, Object> vehicleUsageIntervalInputDefinition() {
Map<String, Object> definition = new LinkedHashMap<>();
definition.put("sessionId", UUID.class);
definition.put("driverKey", String.class);
definition.put("intervalId", String.class);
definition.put("firstSourceIntervalId", String.class);
definition.put("lastSourceIntervalId", String.class);
definition.put("startedAt", OffsetDateTime.class);
definition.put("endedAt", OffsetDateTime.class);
definition.put("startedAtEpochSecond", long.class);
definition.put("endedAtEpochSecond", Long.class);
definition.put("durationSeconds", long.class);
definition.put("odometerBeginKm", Long.class);
definition.put("odometerEndKm", Long.class);
definition.put("registrationKey", String.class);
definition.put("vehicleKey", String.class);
definition.put("sourceKind", String.class);
definition.put("sourceIntervalIds", java.util.List.class);
return definition;
}
private Map<String, Object> toActivityIntervalInputMap(
UUID sessionId,
String driverKey,
ResolvedActivityInterval interval
) {
Map<String, Object> event = new LinkedHashMap<>();
event.put("sessionId", sessionId);
event.put("driverKey", driverKey);
event.put("intervalId", interval.intervalId());
event.put("activityType", interval.activityType());
event.put("cardSlot", interval.slot());
event.put("cardStatus", interval.cardStatus());
event.put("drivingStatus", interval.drivingStatus());
event.put("registrationKey", interval.registrationKey());
event.put("vehicleKey", interval.vehicleKey());
event.put("sourceKind", interval.sourceKind());
event.put("firstSourceIntervalId", firstSourceIntervalId(interval));
event.put("lastSourceIntervalId", lastSourceIntervalId(interval));
event.put("startedAt", interval.from());
event.put("endedAt", interval.to());
event.put("startedAtEpochSecond", interval.from().toEpochSecond());
event.put("endedAtEpochSecond", interval.to().toEpochSecond());
event.put("durationSeconds", interval.durationSeconds());
event.put("sourceIntervalIds", interval.sourceIntervalIds());
event.put("synthetic", interval.synthetic());
event.put("clippedToRequestedPeriod", interval.clippedToRequestedPeriod());
event.put("level", interval.level());
return event;
}
private Map<String, Object> toVehicleUsageIntervalInputMap(ResolvedVehicleUsageInterval interval) {
Map<String, Object> event = new LinkedHashMap<>();
event.put("sessionId", interval.sessionId());
event.put("driverKey", interval.driverKey());
event.put("intervalId", interval.intervalId());
event.put("firstSourceIntervalId", firstSourceIntervalId(interval));
event.put("lastSourceIntervalId", lastSourceIntervalId(interval));
event.put("startedAt", interval.from());
event.put("endedAt", interval.to());
event.put("startedAtEpochSecond", interval.from().toEpochSecond());
event.put("endedAtEpochSecond", interval.to() == null ? null : interval.to().toEpochSecond());
event.put("durationSeconds", interval.durationSeconds());
event.put("odometerBeginKm", interval.odometerBeginKm());
event.put("odometerEndKm", interval.odometerEndKm());
event.put("registrationKey", interval.registrationKey());
event.put("vehicleKey", interval.vehicleKey());
event.put("sourceKind", interval.sourceKind());
event.put("sourceIntervalIds", interval.sourceIntervalIds());
return event;
}
private String firstSourceIntervalId(ResolvedActivityInterval interval) {
return interval.sourceIntervalIds().isEmpty() ? interval.intervalId() : interval.sourceIntervalIds().get(0);
}
private String lastSourceIntervalId(ResolvedActivityInterval interval) {
return interval.sourceIntervalIds().isEmpty()
? interval.intervalId()
: interval.sourceIntervalIds().get(interval.sourceIntervalIds().size() - 1);
}
private String firstSourceIntervalId(ResolvedVehicleUsageInterval interval) {
return interval.sourceIntervalIds().isEmpty() ? interval.intervalId() : interval.sourceIntervalIds().get(0);
}
private String lastSourceIntervalId(ResolvedVehicleUsageInterval interval) {
return interval.sourceIntervalIds().isEmpty()
? interval.intervalId()
: interval.sourceIntervalIds().get(interval.sourceIntervalIds().size() - 1);
}
private void collectDrivingInterruptionIntervalEvents(
EventBean[] newData,
List<TachographEsperDrivingInterruptionIntervalEvent> target
) {
if (newData == null) {
return;
}
for (EventBean event : newData) {
long startedAtEpochSecond = (Long) event.get("startedAtEpochSecond");
long endedAtEpochSecond = (Long) event.get("endedAtEpochSecond");
target.add(new TachographEsperDrivingInterruptionIntervalEvent(
(UUID) event.get("sessionId"),
(String) event.get("driverKey"),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC),
(Long) event.get("durationSeconds"),
(String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"),
(String) event.get("nextRegistrationKey"),
(String) event.get("previousVehicleKey"),
(String) event.get("nextVehicleKey")
));
}
}
private void collectVuCardAbsentIntervalEvents(
EventBean[] newData,
List<TachographEsperVuCardAbsentIntervalEvent> target
) {
if (newData == null) {
return;
}
for (EventBean event : newData) {
long startedAtEpochSecond = (Long) event.get("startedAtEpochSecond");
long endedAtEpochSecond = (Long) event.get("endedAtEpochSecond");
target.add(new TachographEsperVuCardAbsentIntervalEvent(
(UUID) event.get("sessionId"),
(String) event.get("driverKey"),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC),
(Long) event.get("durationSeconds"),
(String) event.get("previousUsageIntervalId"),
(String) event.get("nextUsageIntervalId"),
(String) event.get("previousRegistrationKey"),
(String) event.get("nextRegistrationKey"),
(String) event.get("previousVehicleKey"),
(String) event.get("nextVehicleKey")
));
}
}
private void collectPotentialHomeOvernightStayIntervalEvents(
EventBean[] newData,
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> target
) {
if (newData == null) {
return;
}
for (EventBean event : newData) {
long startedAtEpochSecond = (Long) event.get("startedAtEpochSecond");
long endedAtEpochSecond = (Long) event.get("endedAtEpochSecond");
target.add(new TachographEsperPotentialHomeOvernightStayIntervalEvent(
(UUID) event.get("sessionId"),
(String) event.get("driverKey"),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC),
OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC),
(Long) event.get("durationSeconds"),
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"),
(String) event.get("nextRegistrationKey"),
(String) event.get("previousVehicleKey"),
(String) event.get("nextVehicleKey")
));
}
}
private List<TachographEsperDrivingInterruptionIntervalEvent> sortDrivingInterruptionIntervals(
List<TachographEsperDrivingInterruptionIntervalEvent> intervals
) {
return intervals.stream()
.sorted(Comparator.comparing(TachographEsperDrivingInterruptionIntervalEvent::startedAt)
.thenComparing(TachographEsperDrivingInterruptionIntervalEvent::endedAt))
.toList();
}
private List<TachographEsperVuCardAbsentIntervalEvent> sortVuCardAbsentIntervals(
List<TachographEsperVuCardAbsentIntervalEvent> intervals
) {
return intervals.stream()
.sorted(Comparator.comparing(TachographEsperVuCardAbsentIntervalEvent::startedAt)
.thenComparing(TachographEsperVuCardAbsentIntervalEvent::endedAt))
.toList();
}
private List<TachographEsperPotentialHomeOvernightStayIntervalEvent> sortPotentialHomeOvernightStayIntervals(
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> intervals
) {
return intervals.stream()
.sorted(Comparator.comparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::endedAt))
.toList();
}
private String renderDrivingDerivedProjectionBundleEpl(int significantDrivingMinutes, int minimumRestPeriodMinutes) {
return DRIVING_DERIVED_PROJECTION_BUNDLE_EPL_TEMPLATE
.replace(
"${SIGNIFICANT_DRIVING_THRESHOLD_SECONDS}",
Long.toString(Math.max(1, significantDrivingMinutes) * 60L)
)
.replace(
"${MINIMUM_REST_PERIOD_THRESHOLD_SECONDS}",
Long.toString(Math.max(1, minimumRestPeriodMinutes) * 60L)
);
}
private static String loadResource(String path) {
try {
ClassPathResource resource = new ClassPathResource(path);
return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("Cannot load EPL resource: " + path, e);
}
}
}

View File

@ -0,0 +1,448 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent;
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographTimelineEventBundle;
import com.fasterxml.jackson.databind.JsonNode;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
public class EventBackedDriverTimelineBuilder {
private final DriverTimelineEventBuilder eventBuilder;
public EventBackedDriverTimelineBuilder(DriverTimelineEventBuilder eventBuilder) {
this.eventBuilder = eventBuilder;
}
public ResolvedDriverTimeline build(
TachographFileSession session,
DriverExtractionSession driverSession
) {
TachographTimelineEventBundle bundle = eventBuilder.buildEventBundle(session, driverSession);
List<ResolvedActivityInterval> activityIntervals =
reconstructActivityIntervals(bundle.activityEvents());
List<ResolvedVehicleUsageInterval> vehicleUsageIntervals =
reconstructVehicleUsageIntervals(session.sessionId(), driverSession.driverKey(), bundle.vehicleUsageEvents());
List<ExtractedSupportEvent> supportEvents =
reconstructSupportEvents(bundle.supportEvents());
List<ExtractionWarning> warnings = mergeWarnings(session.warnings(), driverSession.warnings());
OffsetDateTime loadedFrom = minTimestamp(activityIntervals, vehicleUsageIntervals, supportEvents);
OffsetDateTime loadedTo = maxTimestamp(activityIntervals, vehicleUsageIntervals, supportEvents);
return new ResolvedDriverTimeline(
resolveSourceKind(session, bundle),
loadedFrom,
loadedTo,
vehicleUsageIntervals,
activityIntervals,
supportEvents,
warnings
);
}
private List<ResolvedActivityInterval> reconstructActivityIntervals(List<EventHubEventDto> activityEvents) {
if (activityEvents == null || activityEvents.isEmpty()) {
return List.of();
}
Map<String, ActivityAccumulator> byIntervalId = new LinkedHashMap<>();
for (EventHubEventDto event : activityEvents) {
if (!"DRIVER_ACTIVITY".equals(event.eventDomain().name())) {
continue;
}
JsonNode raw = raw(event);
String intervalId = text(raw, "intervalId");
if (intervalId == null) {
intervalId = text(raw, "sourceRowId");
}
if (intervalId == null) {
intervalId = event.externalSourceEventId();
}
String resolvedIntervalId = intervalId;
ActivityAccumulator accumulator = byIntervalId.computeIfAbsent(
resolvedIntervalId,
ignored -> new ActivityAccumulator(resolvedIntervalId)
);
accumulator.accept(event, raw);
}
List<ResolvedActivityInterval> result = new ArrayList<>(byIntervalId.size());
for (ActivityAccumulator accumulator : byIntervalId.values()) {
ResolvedActivityInterval interval = accumulator.finish();
if (interval != null) {
result.add(interval);
}
}
result.sort(Comparator.comparing(ResolvedActivityInterval::from)
.thenComparing(ResolvedActivityInterval::to)
.thenComparing(ResolvedActivityInterval::activityType, Comparator.nullsLast(String::compareTo)));
return List.copyOf(result);
}
private List<ResolvedVehicleUsageInterval> reconstructVehicleUsageIntervals(
UUID sessionId,
String driverKey,
List<EventHubEventDto> vehicleUsageEvents
) {
if (vehicleUsageEvents == null || vehicleUsageEvents.isEmpty()) {
return List.of();
}
Map<String, VehicleUsageAccumulator> byIntervalId = new LinkedHashMap<>();
for (EventHubEventDto event : vehicleUsageEvents) {
if (!"DRIVER_CARD".equals(event.eventDomain().name())) {
continue;
}
JsonNode raw = raw(event);
String intervalId = text(raw, "intervalId");
if (intervalId == null) {
intervalId = text(raw, "sourceRowId");
}
if (intervalId == null) {
intervalId = event.externalSourceEventId();
}
String resolvedIntervalId = intervalId;
VehicleUsageAccumulator accumulator = byIntervalId.computeIfAbsent(
resolvedIntervalId,
ignored -> new VehicleUsageAccumulator(sessionId, driverKey, resolvedIntervalId)
);
accumulator.accept(event, raw);
}
List<ResolvedVehicleUsageInterval> result = new ArrayList<>(byIntervalId.size());
for (VehicleUsageAccumulator accumulator : byIntervalId.values()) {
ResolvedVehicleUsageInterval interval = accumulator.finish();
if (interval != null) {
result.add(interval);
}
}
result.sort(Comparator.comparing(ResolvedVehicleUsageInterval::from)
.thenComparing(ResolvedVehicleUsageInterval::to, Comparator.nullsLast(Comparator.naturalOrder())));
return List.copyOf(result);
}
private List<ExtractedSupportEvent> reconstructSupportEvents(List<EventHubEventDto> supportEvents) {
if (supportEvents == null || supportEvents.isEmpty()) {
return List.of();
}
List<ExtractedSupportEvent> result = new ArrayList<>(supportEvents.size());
for (EventHubEventDto event : supportEvents) {
JsonNode raw = raw(event);
String eventId = text(raw, "supportEventId");
if (eventId == null) {
eventId = event.externalSourceEventId();
}
BigDecimal latitude = event.position() == null ? null : event.position().latitude();
BigDecimal longitude = event.position() == null ? null : event.position().longitude();
result.add(new ExtractedSupportEvent(
eventId,
event.occurredAt(),
event.eventDomain().name(),
text(raw, "supportEventType") == null ? event.eventType().name() : text(raw, "supportEventType"),
event.lifecycle().name(),
text(raw, "slot"),
text(raw, "registrationKey"),
text(raw, "vehicleKey"),
text(raw, "country"),
text(raw, "region"),
text(raw, "countryFrom"),
text(raw, "countryTo"),
text(raw, "operation"),
latitude,
longitude,
text(raw, "authenticationStatus"),
longValue(raw, "odometerKm"),
text(raw, "code"),
decimal(raw, "avgSpeedKmh"),
decimal(raw, "maxSpeedKmh"),
text(raw, "rawRecordPath")
));
}
result.sort(Comparator.comparing(ExtractedSupportEvent::occurredAt)
.thenComparing(ExtractedSupportEvent::eventDomain, Comparator.nullsLast(String::compareTo))
.thenComparing(ExtractedSupportEvent::eventId, Comparator.nullsLast(String::compareTo)));
return List.copyOf(result);
}
private List<ExtractionWarning> mergeWarnings(List<ExtractionWarning> sessionWarnings, List<ExtractionWarning> driverWarnings) {
LinkedHashSet<ExtractionWarning> merged = new LinkedHashSet<>();
if (sessionWarnings != null) {
merged.addAll(sessionWarnings);
}
if (driverWarnings != null) {
merged.addAll(driverWarnings);
}
return List.copyOf(merged);
}
private OffsetDateTime minTimestamp(
List<ResolvedActivityInterval> activityIntervals,
List<ResolvedVehicleUsageInterval> vehicleUsageIntervals,
List<ExtractedSupportEvent> supportEvents
) {
OffsetDateTime min = null;
for (ResolvedActivityInterval interval : activityIntervals) {
min = min(min, interval.from());
}
for (ResolvedVehicleUsageInterval interval : vehicleUsageIntervals) {
min = min(min, interval.from());
}
for (ExtractedSupportEvent supportEvent : supportEvents) {
min = min(min, supportEvent.occurredAt());
}
return min;
}
private OffsetDateTime maxTimestamp(
List<ResolvedActivityInterval> activityIntervals,
List<ResolvedVehicleUsageInterval> vehicleUsageIntervals,
List<ExtractedSupportEvent> supportEvents
) {
OffsetDateTime max = null;
for (ResolvedActivityInterval interval : activityIntervals) {
max = max(max, interval.to());
}
for (ResolvedVehicleUsageInterval interval : vehicleUsageIntervals) {
max = max(max, interval.to());
}
for (ExtractedSupportEvent supportEvent : supportEvents) {
max = max(max, supportEvent.occurredAt());
}
return max;
}
private String resolveSourceKind(TachographFileSession session, TachographTimelineEventBundle bundle) {
for (EventHubEventDto event : bundle.allEvents()) {
JsonNode raw = raw(event);
String sourceKind = text(raw, "sourceKind");
if (sourceKind != null) {
return sourceKind;
}
if (event.packageInfo() != null && event.packageInfo().eventSource() != null
&& event.packageInfo().eventSource().sourceKind() != null) {
return event.packageInfo().eventSource().sourceKind();
}
}
return session.metadata().driverCardFile() ? "DRIVER_CARD" : "VEHICLE_UNIT";
}
private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isBefore(right) ? left : right;
}
private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isAfter(right) ? left : right;
}
private JsonNode raw(EventHubEventDto event) {
JsonNode payload = event.payload();
if (payload == null || payload.isMissingNode()) {
return null;
}
JsonNode raw = payload.get("raw");
return raw == null || raw.isNull() ? payload : raw;
}
private String text(JsonNode node, String field) {
if (node == null || field == null) {
return null;
}
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : value.asText(null);
}
private boolean booleanValue(JsonNode node, String field) {
if (node == null || field == null) {
return false;
}
JsonNode value = node.get(field);
return value != null && !value.isNull() && value.asBoolean(false);
}
private Long longValue(JsonNode node, String field) {
if (node == null || field == null) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
return value.isNumber() ? value.asLong() : Long.parseLong(value.asText());
}
private BigDecimal decimal(JsonNode node, String field) {
if (node == null || field == null) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
return value.decimalValue();
}
String text = value.asText(null);
return text == null || text.isBlank() ? null : new BigDecimal(text);
}
private List<String> stringList(JsonNode node, String field) {
if (node == null || field == null) {
return List.of();
}
JsonNode value = node.get(field);
if (value == null || value.isNull() || !value.isArray()) {
return List.of();
}
List<String> result = new ArrayList<>();
value.forEach(item -> {
String text = item == null || item.isNull() ? null : item.asText(null);
if (text != null && !text.isBlank()) {
result.add(text);
}
});
return List.copyOf(result);
}
private String detailText(EventHubEventDto event, String field) {
if (event.eventDetails() == null || event.eventDetails().attributes() == null) {
return null;
}
JsonNode value = event.eventDetails().attributes().get(field);
return value == null || value.isNull() ? null : value.asText(null);
}
private String activityType(EventHubEventDto event) {
return switch (event.eventType()) {
case DRIVE -> "DRIVE";
case WORK -> "WORK";
case AVAILABILITY -> "AVAILABILITY";
case BREAK_REST -> "BREAK_REST";
default -> "UNKNOWN";
};
}
private Long toKilometers(Long meters) {
return meters == null ? null : meters / 1_000L;
}
private final class ActivityAccumulator {
private final String intervalId;
private OffsetDateTime startedAt;
private OffsetDateTime endedAt;
private EventHubEventDto sample;
private JsonNode raw;
private ActivityAccumulator(String intervalId) {
this.intervalId = intervalId;
}
private void accept(EventHubEventDto event, JsonNode raw) {
if (sample == null) {
sample = event;
this.raw = raw;
}
if (event.lifecycle() == at.procon.eventhub.dto.EventLifecycle.START) {
startedAt = event.occurredAt();
} else if (event.lifecycle() == at.procon.eventhub.dto.EventLifecycle.END) {
endedAt = event.occurredAt();
}
}
private ResolvedActivityInterval finish() {
if (sample == null || startedAt == null || endedAt == null || !endedAt.isAfter(startedAt)) {
return null;
}
return new ResolvedActivityInterval(
intervalId,
startedAt,
endedAt,
java.time.Duration.between(startedAt, endedAt).getSeconds(),
activityType(sample),
detailText(sample, "cardSlot"),
detailText(sample, "cardStatus"),
detailText(sample, "drivingStatus"),
text(raw, "registrationKey"),
text(raw, "vehicleKey"),
text(raw, "sourceKind"),
stringList(raw, "sourceRowIds"),
booleanValue(raw, "synthetic"),
booleanValue(raw, "clippedToRequestedPeriod"),
text(raw, "level") == null ? "RAW_INTERVAL" : text(raw, "level")
);
}
}
private final class VehicleUsageAccumulator {
private final UUID sessionId;
private final String driverKey;
private final String intervalId;
private OffsetDateTime startedAt;
private OffsetDateTime endedAt;
private Long odometerBeginKm;
private Long odometerEndKm;
private JsonNode raw;
private VehicleUsageAccumulator(UUID sessionId, String driverKey, String intervalId) {
this.sessionId = sessionId;
this.driverKey = driverKey;
this.intervalId = intervalId;
}
private void accept(EventHubEventDto event, JsonNode raw) {
if (this.raw == null) {
this.raw = raw;
}
if (event.eventType() == at.procon.eventhub.dto.EventType.CARD_INSERTED) {
startedAt = event.occurredAt();
odometerBeginKm = toKilometers(event.odometerM());
} else if (event.eventType() == at.procon.eventhub.dto.EventType.CARD_WITHDRAWN) {
endedAt = event.occurredAt();
odometerEndKm = toKilometers(event.odometerM());
}
}
private ResolvedVehicleUsageInterval finish() {
if (startedAt == null) {
return null;
}
return ResolvedVehicleUsageInterval.resolved(
sessionId,
driverKey,
intervalId,
startedAt,
endedAt,
odometerBeginKm,
odometerEndKm,
text(raw, "registrationKey"),
text(raw, "vehicleKey"),
text(raw, "sourceKind"),
stringList(raw, "sourceRowIds")
);
}
}
}

View File

@ -0,0 +1,637 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.dto.CardSlot;
import at.procon.eventhub.dto.CardStatus;
import at.procon.eventhub.dto.DriverCardRefDto;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.DrivingStatus;
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.SourcePackageRefDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import at.procon.eventhub.service.EventDetailsFactory;
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.ExtractedSupportEvent;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration;
import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographTimelineEventBundle;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineEventBuilder {
private final DriverTimelineBuilder driverTimelineBuilder;
private final DriverKeyFactory driverKeyFactory;
private final VehicleKeyFactory vehicleKeyFactory;
private final EventDetailsFactory detailsFactory;
public IntervalBackedDriverTimelineEventBuilder(
DriverTimelineBuilder driverTimelineBuilder,
DriverKeyFactory driverKeyFactory,
VehicleKeyFactory vehicleKeyFactory,
EventDetailsFactory detailsFactory
) {
this.driverTimelineBuilder = driverTimelineBuilder;
this.driverKeyFactory = driverKeyFactory;
this.vehicleKeyFactory = vehicleKeyFactory;
this.detailsFactory = detailsFactory;
}
@Override
public TachographTimelineEventBundle buildEventBundle(
TachographFileSession session,
DriverExtractionSession driverSession
) {
ResolvedDriverTimeline timeline = driverTimelineBuilder.build(session, driverSession);
return buildEventBundle(session, driverSession, timeline);
}
@Override
public TachographTimelineEventBundle buildEventBundle(
TachographFileSession session,
DriverExtractionSession driverSession,
ResolvedDriverTimeline timeline
) {
if (session == null || driverSession == null || timeline == null) {
return new TachographTimelineEventBundle(List.of(), List.of(), List.of());
}
Map<String, ExtractedVehicleRegistration> registrationsByKey = new LinkedHashMap<>();
for (ExtractedVehicleRegistration registration : driverSession.vehicleRegistrations()) {
registrationsByKey.put(registration.registrationKey(), registration);
}
Map<String, ExtractedVehicle> vehiclesByKey = new LinkedHashMap<>();
for (ExtractedVehicle vehicle : driverSession.vehicles()) {
vehiclesByKey.put(vehicle.vehicleKey(), vehicle);
}
DriverRefDto driverRef = driverRef(driverSession);
EventSourceDto eventSource = eventSource(session, timeline);
SourcePackageRefDto sourcePackageRef = sourcePackageRef(session, timeline);
List<EventHubEventDto> activityEvents = buildActivityEvents(
session,
timeline.activityIntervals(),
driverRef,
registrationsByKey,
vehiclesByKey,
eventSource,
sourcePackageRef
);
List<EventHubEventDto> vehicleUsageEvents = buildVehicleUsageEvents(
session,
timeline.vehicleUsageIntervals(),
driverRef,
registrationsByKey,
vehiclesByKey,
eventSource,
sourcePackageRef
);
List<EventHubEventDto> supportEvents = buildSupportEvents(
session,
timeline.supportEvents(),
driverRef,
registrationsByKey,
vehiclesByKey,
eventSource,
sourcePackageRef
);
return new TachographTimelineEventBundle(activityEvents, vehicleUsageEvents, supportEvents);
}
private List<EventHubEventDto> buildActivityEvents(
TachographFileSession session,
List<ResolvedActivityInterval> intervals,
DriverRefDto driverRef,
Map<String, ExtractedVehicleRegistration> registrationsByKey,
Map<String, ExtractedVehicle> vehiclesByKey,
EventSourceDto eventSource,
SourcePackageRefDto sourcePackageRef
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
List<EventHubEventDto> events = new ArrayList<>(intervals.size() * 2);
for (ResolvedActivityInterval interval : intervals) {
VehicleRefDto vehicleRef = vehicleRef(interval.registrationKey(), interval.vehicleKey(), registrationsByKey, vehiclesByKey);
EventType eventType = activityEventType(interval.activityType());
EventDetailsDto details = detailsFactory.driverActivity(
cardSlot(interval.slot()),
cardStatus(interval.cardStatus()),
drivingStatus(interval.drivingStatus())
);
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("intervalId", interval.intervalId());
raw.put("sourceRowId", interval.intervalId());
raw.put("sourceRowIds", interval.sourceIntervalIds());
raw.put("startedAt", timeText(interval.from()));
raw.put("endedAt", timeText(interval.to()));
raw.put("durationSeconds", interval.durationSeconds());
raw.put("sourceKind", interval.sourceKind());
raw.put("registrationKey", interval.registrationKey());
raw.put("vehicleKey", interval.vehicleKey());
raw.put("synthetic", interval.synthetic());
raw.put("clippedToRequestedPeriod", interval.clippedToRequestedPeriod());
raw.put("level", interval.level());
events.add(event(
session,
interval.from(),
EventDomain.DRIVER_ACTIVITY,
eventType,
EventLifecycle.START,
eventSource,
driverRef,
vehicleRef,
null,
null,
details,
sourcePackageRef,
raw,
isManualEntry(interval.cardStatus(), interval.drivingStatus()),
"ACTIVITY",
interval.intervalId()
));
events.add(event(
session,
interval.to(),
EventDomain.DRIVER_ACTIVITY,
eventType,
EventLifecycle.END,
eventSource,
driverRef,
vehicleRef,
null,
null,
details,
sourcePackageRef,
raw,
isManualEntry(interval.cardStatus(), interval.drivingStatus()),
"ACTIVITY",
interval.intervalId()
));
}
return List.copyOf(events);
}
private List<EventHubEventDto> buildVehicleUsageEvents(
TachographFileSession session,
List<ResolvedVehicleUsageInterval> intervals,
DriverRefDto driverRef,
Map<String, ExtractedVehicleRegistration> registrationsByKey,
Map<String, ExtractedVehicle> vehiclesByKey,
EventSourceDto eventSource,
SourcePackageRefDto sourcePackageRef
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
List<EventHubEventDto> events = new ArrayList<>(intervals.size() * 2);
for (ResolvedVehicleUsageInterval interval : intervals) {
VehicleRefDto vehicleRef = vehicleRef(interval.registrationKey(), interval.vehicleKey(), registrationsByKey, vehiclesByKey);
EventDetailsDto insertDetails = detailsFactory.driverCard(
null,
CardStatus.INSERTED,
driverRef == null ? null : driverRef.driverCard()
);
EventDetailsDto withdrawDetails = detailsFactory.driverCard(
null,
CardStatus.NOT_INSERTED,
driverRef == null ? null : driverRef.driverCard()
);
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("intervalId", interval.intervalId());
raw.put("sourceRowId", interval.intervalId());
raw.put("sourceRowIds", interval.sourceIntervalIds());
raw.put("startedAt", timeText(interval.from()));
raw.put("endedAt", timeText(interval.to()));
raw.put("durationSeconds", interval.durationSeconds());
raw.put("registrationKey", interval.registrationKey());
raw.put("vehicleKey", interval.vehicleKey());
raw.put("sourceKind", interval.sourceKind());
raw.put("odometerBeginKm", interval.odometerBeginKm());
raw.put("odometerEndKm", interval.odometerEndKm());
events.add(event(
session,
interval.from(),
EventDomain.DRIVER_CARD,
EventType.CARD_INSERTED,
EventLifecycle.INSERT,
eventSource,
driverRef,
vehicleRef,
odometerMeters(interval.odometerBeginKm()),
null,
insertDetails,
sourcePackageRef,
raw,
false,
"VEHICLE_USAGE",
interval.intervalId()
));
if (interval.to() != null) {
events.add(event(
session,
interval.to(),
EventDomain.DRIVER_CARD,
EventType.CARD_WITHDRAWN,
EventLifecycle.WITHDRAW,
eventSource,
driverRef,
vehicleRef,
odometerMeters(interval.odometerEndKm()),
null,
withdrawDetails,
sourcePackageRef,
raw,
false,
"VEHICLE_USAGE",
interval.intervalId()
));
}
}
return List.copyOf(events);
}
private List<EventHubEventDto> buildSupportEvents(
TachographFileSession session,
List<ExtractedSupportEvent> supportEvents,
DriverRefDto driverRef,
Map<String, ExtractedVehicleRegistration> registrationsByKey,
Map<String, ExtractedVehicle> vehiclesByKey,
EventSourceDto eventSource,
SourcePackageRefDto sourcePackageRef
) {
if (supportEvents == null || supportEvents.isEmpty()) {
return List.of();
}
List<EventHubEventDto> events = new ArrayList<>(supportEvents.size());
for (ExtractedSupportEvent supportEvent : supportEvents) {
EventDomain eventDomain = supportEventDomain(supportEvent.eventDomain());
EventType eventType = supportEventType(eventDomain, supportEvent.eventType(), supportEvent.code());
EventLifecycle lifecycle = supportEventLifecycle(eventDomain, supportEvent.eventType(), supportEvent.eventLifecycle());
boolean manualEntry = isManualPlaceEvent(supportEvent.eventType());
VehicleRefDto vehicleRef = vehicleRef(
supportEvent.registrationKey(),
supportEvent.vehicleKey(),
registrationsByKey,
vehiclesByKey
);
EventDetailsDto details = supportDetails(eventDomain, supportEvent);
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("sourceRowId", supportEvent.eventId());
raw.put("supportEventId", supportEvent.eventId());
raw.put("supportEventType", supportEvent.eventType());
raw.put("slot", supportEvent.slot());
raw.put("registrationKey", supportEvent.registrationKey());
raw.put("vehicleKey", supportEvent.vehicleKey());
raw.put("country", supportEvent.country());
raw.put("region", supportEvent.region());
raw.put("countryFrom", supportEvent.countryFrom());
raw.put("countryTo", supportEvent.countryTo());
raw.put("operation", supportEvent.operation());
raw.put("authenticationStatus", supportEvent.authenticationStatus());
raw.put("odometerKm", supportEvent.odometerKm());
raw.put("code", supportEvent.code());
raw.put("avgSpeedKmh", supportEvent.avgSpeedKmh());
raw.put("maxSpeedKmh", supportEvent.maxSpeedKmh());
raw.put("rawRecordPath", supportEvent.rawRecordPath());
events.add(event(
session,
supportEvent.occurredAt(),
eventDomain,
eventType,
lifecycle,
eventSource,
driverRef,
vehicleRef,
odometerMeters(supportEvent.odometerKm()),
position(supportEvent.latitude(), supportEvent.longitude()),
details,
sourcePackageRef,
raw,
manualEntry,
"SUPPORT",
supportEvent.eventId()
));
}
return List.copyOf(events);
}
private EventHubEventDto event(
TachographFileSession session,
OffsetDateTime occurredAt,
EventDomain eventDomain,
EventType eventType,
EventLifecycle lifecycle,
EventSourceDto eventSource,
DriverRefDto driverRef,
VehicleRefDto vehicleRef,
Long odometerM,
GeoPointDto position,
EventDetailsDto details,
SourcePackageRefDto sourcePackageRef,
Map<String, Object> rawPayload,
boolean manualEntry,
String group,
String sourceId
) {
return new EventHubEventDto(
UUID.randomUUID(),
externalSourceEventId(session.sessionId(), group, sourceId, lifecycle, occurredAt),
driverRef,
vehicleRef,
occurredAt,
null,
OffsetDateTime.now(),
eventDomain,
eventType,
lifecycle,
odometerM,
position,
details,
sourcePackageRef,
detailsFactory.payloadFromMap(Map.of("raw", rawPayload)),
manualEntry,
packageInfo(session, eventSource, eventDomain, occurredAt)
);
}
private DriverRefDto driverRef(DriverExtractionSession driverSession) {
ExtractedDriver driver = driverSession.driver();
ExtractedDriverCard card = driverSession.driverCard();
DriverCardRefDto driverCardRef = card == null || card.cardNumber() == null
? driverCardFromKey(driverSession.driverKey())
: new DriverCardRefDto(card.cardNation(), card.cardNumber());
String sourceDriverId = driver == null || driver.sourceDriverId() == null
? driverKeyFactory.createSourceDriverId(driverSession.driverKey())
: driver.sourceDriverId();
return new DriverRefDto(sourceDriverId, driverCardRef);
}
private DriverCardRefDto driverCardFromKey(String driverKey) {
if (driverKey == null || driverKey.isBlank()) {
return null;
}
int separator = driverKey.indexOf(':');
if (separator < 0) {
return new DriverCardRefDto(null, driverKey);
}
return new DriverCardRefDto(driverKey.substring(0, separator), driverKey.substring(separator + 1));
}
private VehicleRefDto vehicleRef(
String registrationKey,
String vehicleKey,
Map<String, ExtractedVehicleRegistration> registrationsByKey,
Map<String, ExtractedVehicle> vehiclesByKey
) {
ExtractedVehicleRegistration registration = registrationKey == null ? null : registrationsByKey.get(registrationKey);
ExtractedVehicle vehicle = vehicleKey == null ? null : vehiclesByKey.get(vehicleKey);
VehicleRegistrationRefDto registrationRef = registration != null
? new VehicleRegistrationRefDto(registration.registrationNation(), registration.registrationNumber())
: registrationFromKey(registrationKey);
VehicleRefDto vehicleRef = new VehicleRefDto(
vehicle != null ? vehicle.sourceVehicleId() : vehicleKeyFactory.createSourceVehicleId(vehicleKey),
vehicle != null ? vehicle.vin() : vehicleKey,
registration != null ? registration.sourceVehicleRegistrationId() : vehicleKeyFactory.createSourceVehicleRegistrationId(registrationKey),
registrationRef
);
return vehicleRef.hasAnyReference() ? vehicleRef : null;
}
private VehicleRegistrationRefDto registrationFromKey(String registrationKey) {
if (registrationKey == null || registrationKey.isBlank()) {
return null;
}
int separator = registrationKey.indexOf(':');
if (separator < 0) {
return new VehicleRegistrationRefDto(null, registrationKey);
}
return new VehicleRegistrationRefDto(registrationKey.substring(0, separator), registrationKey.substring(separator + 1));
}
private EventSourceDto eventSource(TachographFileSession session, ResolvedDriverTimeline timeline) {
String sourceKind = timeline.sourceKind() == null || timeline.sourceKind().isBlank()
? (session.metadata().driverCardFile() ? "DRIVER_CARD" : "VEHICLE_UNIT")
: timeline.sourceKind();
return new EventSourceDto(
"TACHOGRAPH",
sourceKind,
"TACHOGRAPH_" + sourceKind,
session.metadata().sourceInstanceKey(),
null,
null
);
}
private SourcePackageRefDto sourcePackageRef(TachographFileSession session, ResolvedDriverTimeline timeline) {
return new SourcePackageRefDto(
"TACHOGRAPH_FILE_SESSION",
session.sessionId().toString(),
session.metadata().uploadedFileSha256(),
timeline.loadedFrom(),
timeline.loadedTo(),
session.createdAt().atOffset(ZoneOffset.UTC)
);
}
private EventHubPackageRequest packageInfo(
TachographFileSession session,
EventSourceDto eventSource,
EventDomain eventDomain,
OffsetDateTime occurredAt
) {
LocalDate businessDate = occurredAt == null ? null : occurredAt.toLocalDate();
return new EventHubPackageRequest(
tenantOrDefault(session.metadata().tenantKey()),
eventSource,
null,
ImportScopeDto.tenantAll(null, null),
eventDomain.name(),
businessDate,
"TACHOGRAPH_FILE_SESSION:" + session.sessionId() + ":" + eventDomain.name() + ":" + businessDate
);
}
private EventType activityEventType(String activityType) {
if (activityType == null) {
return EventType.UNKNOWN_ACTIVITY;
}
return switch (activityType) {
case "DRIVE" -> EventType.DRIVE;
case "WORK" -> EventType.WORK;
case "AVAILABILITY" -> EventType.AVAILABILITY;
case "BREAK_REST" -> EventType.BREAK_REST;
default -> EventType.UNKNOWN_ACTIVITY;
};
}
private CardSlot cardSlot(String value) {
return parseEnum(CardSlot.class, value, null);
}
private CardStatus cardStatus(String value) {
return parseEnum(CardStatus.class, value, null);
}
private DrivingStatus drivingStatus(String value) {
return parseEnum(DrivingStatus.class, value, DrivingStatus.UNKNOWN);
}
private boolean isManualEntry(String cardStatus, String drivingStatus) {
return Objects.equals("NOT_INSERTED", normalizeToken(cardStatus))
&& Objects.equals("KNOWN", normalizeToken(drivingStatus));
}
private EventDomain supportEventDomain(String value) {
return switch (normalizeToken(value)) {
case "PLACE" -> EventDomain.PLACE;
case "POSITION" -> EventDomain.POSITION;
case "BORDER_CROSSING" -> EventDomain.BORDER_CROSSING;
case "LOAD_UNLOAD" -> EventDomain.LOAD_UNLOAD;
case "SPECIFIC_CONDITION" -> EventDomain.SPECIFIC_CONDITION;
case "SPEEDING" -> EventDomain.SPEEDING;
default -> EventDomain.TELEMATICS_DATA;
};
}
private EventType supportEventType(EventDomain eventDomain, String eventType, String code) {
EventType explicit = parseEnum(EventType.class, eventType, null);
if (explicit != null) {
return explicit;
}
return switch (eventDomain) {
case PLACE -> EventType.WORKING_DAY_PLACE_RECORDED;
case POSITION -> EventType.POSITION_RECORDED;
case SPECIFIC_CONDITION -> {
String normalizedCode = normalizeToken(code);
if (Objects.equals("FERRY_TRAIN", normalizedCode) || Objects.equals("FERRY_OR_TRAIN", normalizedCode)) {
yield EventType.FERRY_TRAIN;
}
if (Objects.equals("OUT_OF_SCOPE", normalizedCode) || Objects.equals("OUT", normalizedCode)) {
yield EventType.OUT;
}
yield EventType.UNKNOWN_EVENT;
}
case BORDER_CROSSING -> EventType.BORDER_OUTBOUND;
case LOAD_UNLOAD -> EventType.LOAD_UNLOAD;
case SPEEDING -> EventType.SPEEDING;
default -> parseEnum(EventType.class, eventType, EventType.UNKNOWN_EVENT);
};
}
private EventLifecycle supportEventLifecycle(EventDomain eventDomain, String eventType, String lifecycle) {
EventLifecycle explicit = parseEnum(EventLifecycle.class, lifecycle, null);
if (explicit != null) {
return explicit;
}
if (eventDomain == EventDomain.PLACE) {
String normalized = normalizeToken(eventType);
if (normalized != null && normalized.startsWith("BEGIN")) {
return EventLifecycle.BEGIN;
}
if (normalized != null && normalized.startsWith("END")) {
return EventLifecycle.END;
}
}
return EventLifecycle.SNAPSHOT;
}
private boolean isManualPlaceEvent(String eventType) {
String normalized = normalizeToken(eventType);
return normalized != null && normalized.contains("MANUAL");
}
private EventDetailsDto supportDetails(EventDomain eventDomain, ExtractedSupportEvent supportEvent) {
return switch (eventDomain) {
case PLACE -> detailsFactory.place(supportEvent.country(), supportEvent.region());
case POSITION -> detailsFactory.position("GNSS_ACCUMULATED_DRIVING");
case BORDER_CROSSING -> detailsFactory.borderCrossing(supportEvent.countryFrom(), supportEvent.countryTo());
case LOAD_UNLOAD -> detailsFactory.loadUnload(supportEvent.operation());
case SPECIFIC_CONDITION -> detailsFactory.specificCondition();
case SPEEDING -> detailsFactory.speeding(supportEvent.avgSpeedKmh(), supportEvent.maxSpeedKmh(), null);
default -> new EventDetailsDto("TACHOGRAPH_SUPPORT", detailsFactory.payloadFromMap(Map.of()));
};
}
private GeoPointDto position(BigDecimal latitude, BigDecimal longitude) {
return latitude == null || longitude == null ? null : new GeoPointDto(latitude, longitude);
}
private Long odometerMeters(Long kilometers) {
return kilometers == null ? null : kilometers * 1_000L;
}
private String timeText(OffsetDateTime value) {
return value == null ? null : value.toString();
}
private String externalSourceEventId(
UUID sessionId,
String group,
String sourceId,
EventLifecycle lifecycle,
OffsetDateTime occurredAt
) {
return "TACHOGRAPH_FILE_SESSION:"
+ sessionId
+ ":"
+ group
+ ":"
+ sourceId
+ ":"
+ lifecycle.name()
+ ":"
+ occurredAt;
}
private String tenantOrDefault(String value) {
return value == null || value.isBlank() ? "default" : value.trim();
}
private String normalizeToken(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim().toUpperCase().replace('-', '_').replace(' ', '_');
}
private <T extends Enum<T>> T parseEnum(Class<T> type, String value, T fallback) {
String normalized = normalizeToken(value);
if (normalized == null) {
return fallback;
}
try {
return Enum.valueOf(type, normalized);
} catch (IllegalArgumentException ignored) {
return fallback;
}
}
}

View File

@ -13,6 +13,7 @@ import at.procon.eventhub.tachographfilesession.model.ProcessedShiftDrivingEvalu
import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingDerivedProjectionBundle;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent;
@ -33,15 +34,21 @@ public class TachographFileSessionProcessingService {
private final TachographFileSessionRepository repository;
private final DriverTimelineBuilder driverTimelineBuilder;
private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder;
private final EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder;
private final EventHubProperties properties;
public TachographFileSessionProcessingService(
TachographFileSessionRepository repository,
DriverTimelineBuilder driverTimelineBuilder,
DriverTimelineReusableProjectionBuilder reusableProjectionBuilder,
EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder,
EventHubProperties properties
) {
this.repository = repository;
this.driverTimelineBuilder = driverTimelineBuilder;
this.reusableProjectionBuilder = reusableProjectionBuilder;
this.eventBackedDriverTimelineBuilder = eventBackedDriverTimelineBuilder;
this.properties = properties;
}
@ -60,7 +67,7 @@ public class TachographFileSessionProcessingService {
throw new DriverNotFoundInSessionException(sessionId, driverKey);
}
ResolvedDriverTimeline timeline = driverTimelineBuilder.build(session, driver);
ResolvedDriverTimeline timeline = resolveTimeline(session, driver);
OffsetDateTime loadedFrom = timeline.loadedFrom();
OffsetDateTime loadedTo = timeline.loadedTo();
OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null ? loadedFrom : utc(effectiveRequest.occurredFrom());
@ -142,7 +149,7 @@ public class TachographFileSessionProcessingService {
throw new DriverNotFoundInSessionException(sessionId, driverKey);
}
ResolvedDriverTimeline timeline = driverTimelineBuilder.build(session, driver);
ResolvedDriverTimeline timeline = resolveTimeline(session, driver);
OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null ? timeline.loadedFrom() : utc(effectiveRequest.occurredFrom());
OffsetDateTime requestedTo = effectiveRequest.occurredTo() == null ? timeline.loadedTo() : utc(effectiveRequest.occurredTo());
if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) {
@ -161,13 +168,16 @@ public class TachographFileSessionProcessingService {
requestedFrom,
requestedTo
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionIntervals =
driverTimelineBuilder.buildEsperDrivingInterruptionIntervalEvents(
TachographEsperDrivingDerivedProjectionBundle derivedProjectionBundle =
reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle(
sessionId,
driverKey,
timeline,
significantDrivingMinutes
significantDrivingMinutes,
minimumRestPeriodMinutes
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionIntervals =
derivedProjectionBundle.drivingInterruptionIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDrivingInterruptionIntervals,
@ -175,10 +185,7 @@ public class TachographFileSessionProcessingService {
requestedTo
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDailyWeeklyRestCandidateIntervals =
driverTimelineBuilder.buildEsperDailyWeeklyRestCandidateIntervalEvents(
rawDrivingInterruptionIntervals,
minimumRestPeriodMinutes
);
derivedProjectionBundle.dailyWeeklyRestCandidateIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> dailyWeeklyRestCandidateIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDailyWeeklyRestCandidateIntervals,
@ -186,9 +193,7 @@ public class TachographFileSessionProcessingService {
requestedTo
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionVehicleChangeIntervals =
driverTimelineBuilder.buildEsperDrivingInterruptionVehicleChangeIntervalEvents(
rawDailyWeeklyRestCandidateIntervals
);
derivedProjectionBundle.drivingInterruptionVehicleChangeIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDrivingInterruptionVehicleChangeIntervals,
@ -196,13 +201,10 @@ public class TachographFileSessionProcessingService {
requestedTo
);
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals =
driverTimelineBuilder.buildEsperVuCardAbsentIntervalEvents(timeline);
derivedProjectionBundle.vuCardAbsentIntervals();
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals =
clipEsperPotentialHomeOvernightStayIntervalEvents(
driverTimelineBuilder.buildEsperPotentialHomeOvernightStayIntervalEvents(
rawDrivingInterruptionVehicleChangeIntervals,
rawVuCardAbsentIntervals
),
derivedProjectionBundle.potentialHomeOvernightStayIntervals(),
rawVuCardAbsentIntervals,
requestedFrom,
requestedTo
@ -246,6 +248,17 @@ public class TachographFileSessionProcessingService {
);
}
private ResolvedDriverTimeline resolveTimeline(
TachographFileSession session,
DriverExtractionSession driver
) {
if (properties.getTachographFileSession().getProcessing().getTimelineInputMode()
== EventHubProperties.TimelineInputMode.EVENTS) {
return eventBackedDriverTimelineBuilder.build(session, driver);
}
return driverTimelineBuilder.build(session, driver);
}
private List<TachographEsperActivityIntervalEvent> clipEsperActivityIntervalEvents(
List<TachographEsperActivityIntervalEvent> intervals,
OffsetDateTime requestedFrom,

View File

@ -369,6 +369,9 @@ public class VehicleUnitXmlExtractionService {
extractVuPlaceSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuGnssSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuSpecificConditionSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuBorderCrossingSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuLoadUnloadSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuSpeedingSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
}
private void extractVuPlaceSupportEvents(
@ -415,16 +418,22 @@ public class VehicleUnitXmlExtractionService {
occurredAt,
"PLACE",
mapPlaceEntryType(entryType),
null,
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
text(record, "placeRecord/dailyWorkPeriodCountry"),
text(record, "placeRecord/dailyWorkPeriodRegion"),
null,
null,
null,
latitude,
longitude,
authenticationStatus,
longValue(text(record, "placeRecord/vehicleOdometerValue")),
normalizeToken(entryType),
null,
null,
path
),
warnings
@ -480,17 +489,23 @@ public class VehicleUnitXmlExtractionService {
"VUGNSS-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt,
"POSITION",
"GNSS_ACCUMULATED_DRIVING",
"POSITION_RECORDED",
"SNAPSHOT",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
null,
null,
null,
latitude,
longitude,
authenticationStatus,
longValue(text(record, "vehicleOdometerValue")),
null,
null,
null,
path
),
warnings
@ -531,6 +546,7 @@ public class VehicleUnitXmlExtractionService {
}
String conditionCode = normalizeToken(text(record, "specificConditionType"));
String[] specificCondition = mapSpecificCondition(conditionCode);
for (DriverAssignment assignment : assignments) {
addSupportEvent(
driversByKey,
@ -539,7 +555,8 @@ public class VehicleUnitXmlExtractionService {
"VUSC-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt,
"SPECIFIC_CONDITION",
"SPECIFIC_CONDITION",
specificCondition[0],
specificCondition[1],
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
@ -549,7 +566,12 @@ public class VehicleUnitXmlExtractionService {
null,
null,
null,
null,
null,
null,
conditionCode,
null,
null,
path
),
warnings
@ -558,6 +580,247 @@ public class VehicleUnitXmlExtractionService {
}
}
private void extractVuBorderCrossingSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/VehicleUnit/Activities/vuBorderCrossingData/vuBorderCrossingRecord");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/Activities/vuBorderCrossingData/vuBorderCrossingRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(text(record, "gnssPlaceAuthRecord/timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("VU_BORDER_CROSSING_MISSING_TIME", "Vehicle-unit border-crossing record is missing gnssPlaceAuthRecord/timeStamp.", path));
continue;
}
List<DriverAssignment> assignments = new ArrayList<>();
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberDriverSlot"),
"DRIVER",
vuCardIwIntervals
));
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberCodriverSlot"),
"CO_DRIVER",
vuCardIwIntervals
));
assignments = distinctAssignments(assignments);
if (assignments.isEmpty()) {
warnings.add(new ExtractionWarning("VU_BORDER_CROSSING_UNASSIGNED", "Vehicle-unit border-crossing record could not be assigned to an active driver session.", path));
continue;
}
BigDecimal latitude = geoCoordinate(record, "gnssPlaceAuthRecord/geoCoordinates/latitude", true);
BigDecimal longitude = geoCoordinate(record, "gnssPlaceAuthRecord/geoCoordinates/longitude", false);
String authenticationStatus = normalizeToken(text(record, "gnssPlaceAuthRecord/authenticationStatus"));
for (DriverAssignment assignment : assignments) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VUBORDER-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt,
"BORDER_CROSSING",
"BORDER_OUTBOUND",
"OUTBOUND",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
text(record, "countryLeft"),
text(record, "countryEntered"),
null,
latitude,
longitude,
authenticationStatus,
longValue(text(record, "vehicleOdometerValue")),
null,
null,
null,
path
),
warnings
);
}
}
}
private void extractVuLoadUnloadSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/VehicleUnit/Activities/vuLoadUnloadData/vuLoadUnloadRecord");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/Activities/vuLoadUnloadData/vuLoadUnloadRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(text(record, "timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("VU_LOAD_UNLOAD_MISSING_TIME", "Vehicle-unit load/unload record is missing timeStamp.", path));
continue;
}
List<DriverAssignment> assignments = new ArrayList<>();
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberDriverSlot"),
"DRIVER",
vuCardIwIntervals
));
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberCodriverSlot"),
"CO_DRIVER",
vuCardIwIntervals
));
assignments = distinctAssignments(assignments);
if (assignments.isEmpty()) {
warnings.add(new ExtractionWarning("VU_LOAD_UNLOAD_UNASSIGNED", "Vehicle-unit load/unload record could not be assigned to an active driver session.", path));
continue;
}
BigDecimal latitude = geoCoordinate(record, "gnssPlaceAuthRecord/geoCoordinates/latitude", true);
BigDecimal longitude = geoCoordinate(record, "gnssPlaceAuthRecord/geoCoordinates/longitude", false);
String authenticationStatus = normalizeToken(text(record, "gnssPlaceAuthRecord/authenticationStatus"));
String operation = mapOperation(text(record, "operationType"));
for (DriverAssignment assignment : assignments) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VULOAD-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt,
"LOAD_UNLOAD",
operation,
"SNAPSHOT",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
null,
null,
operation,
latitude,
longitude,
authenticationStatus,
longValue(text(record, "vehicleOdometerValue")),
normalizeToken(text(record, "operationType")),
null,
null,
path
),
warnings
);
}
}
}
private void extractVuSpeedingSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/VehicleUnit/EventsFaults/vuOverSpeedingEventData/vuOverSpeedingEventRecords");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/EventsFaults/vuOverSpeedingEventData/vuOverSpeedingEventRecords[" + (i + 1) + "]";
OffsetDateTime beginAt = offsetDateTime(text(record, "eventBeginTime"));
OffsetDateTime endAt = offsetDateTime(text(record, "eventEndTime"));
if (beginAt == null && endAt == null) {
warnings.add(new ExtractionWarning("VU_SPEEDING_MISSING_TIME", "Vehicle-unit speeding record is missing both eventBeginTime and eventEndTime.", path));
continue;
}
List<DriverAssignment> assignments = distinctAssignments(resolveDriverAssignments(
beginAt != null ? beginAt : endAt,
driverKeyFromCardNode(record, "cardNumberDriverSlotBegin"),
"DRIVER",
vuCardIwIntervals
));
if (assignments.isEmpty()) {
warnings.add(new ExtractionWarning("VU_SPEEDING_UNASSIGNED", "Vehicle-unit speeding record could not be assigned to an active driver session.", path));
continue;
}
BigDecimal avgSpeedKmh = decimalValue(text(record, "averageSpeedValue"));
BigDecimal maxSpeedKmh = decimalValue(text(record, "maxSpeedValue"));
for (DriverAssignment assignment : assignments) {
if (beginAt != null) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VUSPEED-" + (i + 1) + "-" + assignment.driverKey() + "-BEGIN",
beginAt,
"SPEEDING",
"SPEEDING",
"BEGIN",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
null,
null,
null,
null,
null,
null,
null,
normalizeToken(text(record, "eventType")),
avgSpeedKmh,
maxSpeedKmh,
path
),
warnings
);
}
if (endAt != null) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VUSPEED-" + (i + 1) + "-" + assignment.driverKey() + "-END",
endAt,
"SPEEDING",
"SPEEDING",
"END",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
null,
null,
null,
null,
null,
null,
null,
normalizeToken(text(record, "eventType")),
avgSpeedKmh,
maxSpeedKmh,
path
),
warnings
);
}
}
}
}
private boolean isPartiallyCovered(ExtractedCardActivityInterval interval, List<ActivitySegment> segments) {
if (segments.isEmpty()) {
return true;
@ -696,6 +959,13 @@ public class VehicleUnitXmlExtractionService {
return Long.parseLong(value.trim());
}
private BigDecimal decimalValue(String value) {
if (value == null || value.isBlank()) {
return null;
}
return new BigDecimal(value.trim());
}
private BigDecimal geoCoordinate(Object node, String expression, boolean latitude) {
String value = text(node, expression);
if (value == null) {
@ -759,6 +1029,32 @@ public class VehicleUnitXmlExtractionService {
};
}
private String[] mapSpecificCondition(String conditionCode) {
String normalized = normalizeToken(conditionCode);
if (normalized == null) {
return new String[]{"UNKNOWN_EVENT", "SNAPSHOT"};
}
return switch (normalized) {
case "1" -> new String[]{"OUT", "BEGIN"};
case "2" -> new String[]{"OUT", "END"};
case "3" -> new String[]{"FERRY_TRAIN", "BEGIN"};
case "4" -> new String[]{"FERRY_TRAIN", "END"};
default -> new String[]{"UNKNOWN_EVENT", "SNAPSHOT"};
};
}
private String mapOperation(String operationType) {
String normalized = normalizeToken(operationType);
if (normalized == null) {
return "LOAD_UNLOAD";
}
return switch (normalized) {
case "1", "LOAD" -> "LOAD";
case "2", "UNLOAD" -> "UNLOAD";
default -> "LOAD_UNLOAD";
};
}
private String joinCardNumber(Element node, String basePath) {
String driverIdentification = text(node, basePath + "/driverIdentification");
if (driverIdentification == null) {

View File

@ -0,0 +1,253 @@
create schema SignificantDrivingInterval(
sessionId java.util.UUID,
driverKey string,
firstSourceIntervalId string,
lastSourceIntervalId string,
startedAtEpochSecond long,
endedAtEpochSecond long,
durationSeconds long,
registrationKey string,
vehicleKey string
);
create schema DrivingInterruptionInterval(
sessionId java.util.UUID,
driverKey string,
startedAtEpochSecond long,
endedAtEpochSecond long,
durationSeconds long,
previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string,
previousRegistrationKey string,
nextRegistrationKey string,
previousVehicleKey string,
nextVehicleKey string
);
create schema DailyWeeklyRestCandidateInterval(
sessionId java.util.UUID,
driverKey string,
startedAtEpochSecond long,
endedAtEpochSecond long,
durationSeconds long,
previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string,
previousRegistrationKey string,
nextRegistrationKey string,
previousVehicleKey string,
nextVehicleKey string
);
create schema DrivingInterruptionVehicleChangeInterval(
sessionId java.util.UUID,
driverKey string,
startedAtEpochSecond long,
endedAtEpochSecond long,
durationSeconds long,
previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string,
previousRegistrationKey string,
nextRegistrationKey string,
previousVehicleKey string,
nextVehicleKey string
);
create context PerDriver partition by driverKey from TachographVehicleUsageIntervalInputEvent;
create schema VuCardAbsentInterval(
sessionId java.util.UUID,
driverKey string,
startedAtEpochSecond long,
endedAtEpochSecond long,
durationSeconds long,
previousUsageIntervalId string,
nextUsageIntervalId string,
previousRegistrationKey string,
nextRegistrationKey string,
previousVehicleKey string,
nextVehicleKey string
);
create schema PotentialHomeOvernightStayInterval(
sessionId java.util.UUID,
driverKey string,
startedAtEpochSecond long,
endedAtEpochSecond long,
durationSeconds long,
unknownDurationSeconds long,
unknownCoveragePercent double,
previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string,
previousRegistrationKey string,
nextRegistrationKey string,
previousVehicleKey string,
nextVehicleKey string
);
insert into SignificantDrivingInterval
select
sessionId,
driverKey,
firstSourceIntervalId,
lastSourceIntervalId,
startedAtEpochSecond,
endedAtEpochSecond,
durationSeconds,
registrationKey,
vehicleKey
from TachographActivityIntervalInputEvent(activityType = 'DRIVE', durationSeconds > ${SIGNIFICANT_DRIVING_THRESHOLD_SECONDS});
create window PreviousSignificantDrivingInterval#unique(driverKey) as SignificantDrivingInterval;
on SignificantDrivingInterval as next
insert into DrivingInterruptionInterval
select
priorInterval.sessionId as sessionId,
priorInterval.driverKey as driverKey,
priorInterval.endedAtEpochSecond as startedAtEpochSecond,
next.startedAtEpochSecond as endedAtEpochSecond,
next.startedAtEpochSecond - priorInterval.endedAtEpochSecond as durationSeconds,
priorInterval.lastSourceIntervalId as previousDrivingSourceIntervalId,
next.firstSourceIntervalId as nextDrivingSourceIntervalId,
priorInterval.registrationKey as previousRegistrationKey,
next.registrationKey as nextRegistrationKey,
priorInterval.vehicleKey as previousVehicleKey,
next.vehicleKey as nextVehicleKey
from PreviousSignificantDrivingInterval as priorInterval
where priorInterval.driverKey = next.driverKey
and next.startedAtEpochSecond > priorInterval.endedAtEpochSecond;
@Priority(20)
on SignificantDrivingInterval
delete from PreviousSignificantDrivingInterval;
@Priority(10)
on SignificantDrivingInterval as current
insert into PreviousSignificantDrivingInterval
select *;
insert into DailyWeeklyRestCandidateInterval
select *
from DrivingInterruptionInterval(durationSeconds > ${MINIMUM_REST_PERIOD_THRESHOLD_SECONDS});
insert into DrivingInterruptionVehicleChangeInterval
select *
from DailyWeeklyRestCandidateInterval(
previousRegistrationKey is not null,
nextRegistrationKey is not null,
previousRegistrationKey != nextRegistrationKey
);
context PerDriver
create window PreviousVehicleUsageInterval#lastevent as TachographVehicleUsageIntervalInputEvent;
@Priority(30)
context PerDriver
on TachographVehicleUsageIntervalInputEvent as next
insert into VuCardAbsentInterval
select
priorInterval.sessionId as sessionId,
priorInterval.driverKey as driverKey,
priorInterval.endedAtEpochSecond + 1L as startedAtEpochSecond,
next.startedAtEpochSecond as endedAtEpochSecond,
next.startedAtEpochSecond - (priorInterval.endedAtEpochSecond + 1L) as durationSeconds,
priorInterval.lastSourceIntervalId as previousUsageIntervalId,
next.firstSourceIntervalId as nextUsageIntervalId,
priorInterval.registrationKey as previousRegistrationKey,
next.registrationKey as nextRegistrationKey,
priorInterval.vehicleKey as previousVehicleKey,
next.vehicleKey as nextVehicleKey
from PreviousVehicleUsageInterval as priorInterval
where priorInterval.endedAt is not null
and next.startedAt is not null
and next.startedAtEpochSecond > priorInterval.endedAtEpochSecond + 1L;
@Priority(20)
context PerDriver
on TachographVehicleUsageIntervalInputEvent
delete from PreviousVehicleUsageInterval;
@Priority(10)
context PerDriver
on TachographVehicleUsageIntervalInputEvent as current
insert into PreviousVehicleUsageInterval
select *;
insert into PotentialHomeOvernightStayInterval
select
c.sessionId as sessionId,
c.driverKey as driverKey,
c.startedAtEpochSecond as startedAtEpochSecond,
c.endedAtEpochSecond as endedAtEpochSecond,
c.durationSeconds as durationSeconds,
sum(
case
when u.startedAtEpochSecond <= c.startedAtEpochSecond and u.endedAtEpochSecond >= c.endedAtEpochSecond
then c.durationSeconds
when u.startedAtEpochSecond <= c.startedAtEpochSecond
then u.endedAtEpochSecond - c.startedAtEpochSecond
when u.endedAtEpochSecond >= c.endedAtEpochSecond
then c.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond
end
) as unknownDurationSeconds,
(sum(
case
when u.startedAtEpochSecond <= c.startedAtEpochSecond and u.endedAtEpochSecond >= c.endedAtEpochSecond
then c.durationSeconds
when u.startedAtEpochSecond <= c.startedAtEpochSecond
then u.endedAtEpochSecond - c.startedAtEpochSecond
when u.endedAtEpochSecond >= c.endedAtEpochSecond
then c.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond
end
) * 100.0d) / c.durationSeconds as unknownCoveragePercent,
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 DrivingInterruptionVehicleChangeInterval as c unidirectional,
VuCardAbsentInterval#keepall as u
where u.driverKey = c.driverKey
and u.startedAtEpochSecond < c.endedAtEpochSecond
and u.endedAtEpochSecond > c.startedAtEpochSecond
group by
c.sessionId,
c.driverKey,
c.startedAtEpochSecond,
c.endedAtEpochSecond,
c.durationSeconds,
c.previousDrivingSourceIntervalId,
c.nextDrivingSourceIntervalId,
c.previousRegistrationKey,
c.nextRegistrationKey,
c.previousVehicleKey,
c.nextVehicleKey
having sum(
case
when u.startedAtEpochSecond <= c.startedAtEpochSecond and u.endedAtEpochSecond >= c.endedAtEpochSecond
then c.durationSeconds
when u.startedAtEpochSecond <= c.startedAtEpochSecond
then u.endedAtEpochSecond - c.startedAtEpochSecond
when u.endedAtEpochSecond >= c.endedAtEpochSecond
then c.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond
end
) * 100L >= c.durationSeconds * 95L;
@name('drivingInterruptionIntervals')
select * from DrivingInterruptionInterval;
@name('dailyWeeklyRestCandidateIntervals')
select * from DailyWeeklyRestCandidateInterval;
@name('drivingInterruptionVehicleChangeIntervals')
select * from DrivingInterruptionVehicleChangeInterval;
@name('vuCardAbsentIntervals')
select * from VuCardAbsentInterval;
@name('potentialHomeOvernightStayIntervals')
select * from PotentialHomeOvernightStayInterval;

View File

@ -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");
}
}

View File

@ -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<at.procon.eventhub.dto.EventHubEventDto> 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)
);
}
}

View File

@ -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)
);
}
}

View File

@ -49,6 +49,13 @@ class DriverCardXmlExtractionServiceTest {
assertThat(driver.cardActivityIntervals().get(0).registrationKey()).isEqualTo("12:W-12345A");
assertThat(driver.cardActivityIntervals().get(1).to()).isEqualTo(driver.cardVehicleUsageIntervals().get(0).to().plusSeconds(1));
assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B");
assertThat(driver.supportEvents()).hasSize(5);
assertThat(driver.supportEvents()).extracting("eventDomain")
.containsExactly("PLACE", "POSITION", "SPECIFIC_CONDITION", "BORDER_CROSSING", "LOAD_UNLOAD");
assertThat(driver.supportEvents().get(2).eventType()).isEqualTo("FERRY_TRAIN");
assertThat(driver.supportEvents().get(2).eventLifecycle()).isEqualTo("BEGIN");
assertThat(driver.supportEvents().get(3).countryFrom()).isEqualTo("12");
assertThat(driver.supportEvents().get(4).operation()).isEqualTo("UNLOAD");
}
@Test

View File

@ -109,6 +109,92 @@ final class DriverCardXmlSamples {
</cardDriverActivity>
<signature></signature>
</DriverActivityData>
<Places>
<cardPlaceDailyWorkPeriod>
<placeRecords>
<entryTime>2026-04-01T08:00:00Z</entryTime>
<entryTypeDailyWorkPeriod>0</entryTypeDailyWorkPeriod>
<dailyWorkPeriodCountry>12</dailyWorkPeriodCountry>
<dailyWorkPeriodRegion>9</dailyWorkPeriodRegion>
<vehicleOdometerValue>1000</vehicleOdometerValue>
<entryGnssPlaceRecord>
<timeStamp>2026-04-01T08:00:00Z</timeStamp>
<gnssAccuracy>5</gnssAccuracy>
<geoCoordinates>
<latitude>48.2082</latitude>
<longitude>16.3738</longitude>
</geoCoordinates>
<authenticationStatus>1</authenticationStatus>
</entryGnssPlaceRecord>
</placeRecords>
</cardPlaceDailyWorkPeriod>
<signature></signature>
</Places>
<SpecificConditions>
<specificConditionData>
<specificConditionRecord>
<entryTime>2026-04-01T10:30:00Z</entryTime>
<specificConditionType>3</specificConditionType>
</specificConditionRecord>
</specificConditionData>
<signature></signature>
</SpecificConditions>
<GnssPlaces>
<gnssAccumulatedDriving>
<gnssAccumulatedDrivingRecord>
<timeStamp>2026-04-01T09:30:00Z</timeStamp>
<gnssPlaceRecord>
<timeStamp>2026-04-01T09:30:00Z</timeStamp>
<gnssAccuracy>4</gnssAccuracy>
<geoCoordinates>
<latitude>48.2000</latitude>
<longitude>16.3600</longitude>
</geoCoordinates>
<authenticationStatus>1</authenticationStatus>
</gnssPlaceRecord>
<vehicleOdometerValue>1050</vehicleOdometerValue>
</gnssAccumulatedDrivingRecord>
</gnssAccumulatedDriving>
<signature></signature>
</GnssPlaces>
<BorderCrossings>
<cardBorderCrossings>
<cardBorderCrossingRecord>
<countryLeft>12</countryLeft>
<countryEntered>56</countryEntered>
<gnssPlaceAuthRecord>
<timeStamp>2026-04-01T12:15:00Z</timeStamp>
<gnssAccuracy>4</gnssAccuracy>
<geoCoordinates>
<latitude>48.3000</latitude>
<longitude>16.5000</longitude>
</geoCoordinates>
<authenticationStatus>0</authenticationStatus>
</gnssPlaceAuthRecord>
<vehicleOdometerValue>1120</vehicleOdometerValue>
</cardBorderCrossingRecord>
</cardBorderCrossings>
<signature></signature>
</BorderCrossings>
<LoadUnloadOperations>
<cardLoadUnloadOperations>
<cardLoadUnloadRecord>
<timeStamp>2026-04-01T12:45:00Z</timeStamp>
<operationType>2</operationType>
<gnssPlaceAuthRecord>
<timeStamp>2026-04-01T12:45:00Z</timeStamp>
<gnssAccuracy>3</gnssAccuracy>
<geoCoordinates>
<latitude>48.3100</latitude>
<longitude>16.5100</longitude>
</geoCoordinates>
<authenticationStatus>1</authenticationStatus>
</gnssPlaceAuthRecord>
<vehicleOdometerValue>1130</vehicleOdometerValue>
</cardLoadUnloadRecord>
</cardLoadUnloadOperations>
<signature></signature>
</LoadUnloadOperations>
</DriverCard>
""";
}

View File

@ -77,6 +77,7 @@ class DriverTimelineBuilderTest {
OffsetDateTime.parse("2026-05-01T08:30:00Z"),
"PLACE",
"BEGIN_DAILY_WORK_PERIOD",
null,
"DRIVER",
"12:REG-1",
"VIN-1",
@ -87,6 +88,11 @@ class DriverTimelineBuilderTest {
null,
null,
null,
null,
null,
null,
null,
null,
"d"
)),
List.of(new ExtractionWarning("W1", "warning", "/x"))

View File

@ -0,0 +1,129 @@
package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
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.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingDerivedProjectionBundle;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
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 DriverTimelineReusableProjectionBuilderTest {
private final DriverTimelineBuilder legacyBuilder = new DriverTimelineBuilder();
private final DriverTimelineReusableProjectionBuilder reusableBuilder =
new DriverTimelineReusableProjectionBuilder(legacyBuilder);
@Test
void matchesLegacyDrivingDerivedProjectionChain() {
DriverExtractionSession driver = new DriverExtractionSession(
"12:123",
null,
null,
List.of(),
List.of(),
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"
),
new ExtractedCardVehicleUsageInterval(
"CVU-2",
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
OffsetDateTime.parse("2026-05-02T02:00:00Z"),
201L,
260L,
"12:REG-2",
"VIN-2",
"vu-2"
)
),
List.of(
new ExtractedCardActivityInterval(
"ACT-1",
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T10:00:00Z"),
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"a"
),
new ExtractedCardActivityInterval(
"ACT-2",
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
OffsetDateTime.parse("2026-05-02T00:30:00Z"),
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-2",
"VIN-2",
"b"
)
),
List.of(),
List.of()
);
TachographFileSession session = 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, 2, 2, 1, 1, 0),
List.of(),
Instant.now(),
Instant.now().plus(4, ChronoUnit.HOURS)
);
ResolvedDriverTimeline timeline = legacyBuilder.build(session, driver);
List<TachographEsperDrivingInterruptionIntervalEvent> legacyDrivingInterruptions =
legacyBuilder.buildEsperDrivingInterruptionIntervalEvents(session.sessionId(), driver.driverKey(), timeline, 3);
List<TachographEsperDrivingInterruptionIntervalEvent> legacyDailyWeeklyRestCandidates =
legacyBuilder.buildEsperDailyWeeklyRestCandidateIntervalEvents(legacyDrivingInterruptions, 720);
List<TachographEsperDrivingInterruptionIntervalEvent> legacyDrivingInterruptionVehicleChanges =
legacyBuilder.buildEsperDrivingInterruptionVehicleChangeIntervalEvents(legacyDailyWeeklyRestCandidates);
List<TachographEsperVuCardAbsentIntervalEvent> legacyVuCardAbsentIntervals =
legacyBuilder.buildEsperVuCardAbsentIntervalEvents(timeline);
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> legacyPotentialHomeOvernightStays =
legacyBuilder.buildEsperPotentialHomeOvernightStayIntervalEvents(
legacyDrivingInterruptionVehicleChanges,
legacyVuCardAbsentIntervals
);
TachographEsperDrivingDerivedProjectionBundle reusableBundle =
reusableBuilder.buildEsperDrivingDerivedProjectionBundle(
session.sessionId(),
driver.driverKey(),
timeline,
3,
720
);
assertThat(reusableBundle.drivingInterruptionIntervals()).containsExactlyElementsOf(legacyDrivingInterruptions);
assertThat(reusableBundle.dailyWeeklyRestCandidateIntervals()).containsExactlyElementsOf(legacyDailyWeeklyRestCandidates);
assertThat(reusableBundle.drivingInterruptionVehicleChangeIntervals()).containsExactlyElementsOf(legacyDrivingInterruptionVehicleChanges);
assertThat(reusableBundle.vuCardAbsentIntervals()).containsExactlyElementsOf(legacyVuCardAbsentIntervals);
assertThat(reusableBundle.potentialHomeOvernightStayIntervals()).containsExactlyElementsOf(legacyPotentialHomeOvernightStays);
}
}

View File

@ -0,0 +1,116 @@
package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
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 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 EventBackedDriverTimelineBuilderTest {
private final DriverTimelineBuilder directBuilder = new DriverTimelineBuilder();
private final EventBackedDriverTimelineBuilder eventBackedBuilder =
new EventBackedDriverTimelineBuilder(
new IntervalBackedDriverTimelineEventBuilder(
directBuilder,
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
)
);
@Test
void reconstructsTimelineFromEvents() {
DriverExtractionSession driver = 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()
);
TachographFileSession session = 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)
);
ResolvedDriverTimeline direct = directBuilder.build(session, driver);
ResolvedDriverTimeline reconstructed = eventBackedBuilder.build(session, driver);
assertThat(reconstructed.sourceKind()).isEqualTo(direct.sourceKind());
assertThat(reconstructed.loadedFrom()).isEqualTo(direct.loadedFrom());
assertThat(reconstructed.loadedTo()).isEqualTo(direct.loadedTo());
assertThat(reconstructed.activityIntervals()).containsExactlyElementsOf(direct.activityIntervals());
assertThat(reconstructed.vehicleUsageIntervals()).containsExactlyElementsOf(direct.vehicleUsageIntervals());
assertThat(reconstructed.supportEvents()).containsExactlyElementsOf(direct.supportEvents());
}
}

View File

@ -0,0 +1,185 @@
package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
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.model.TachographTimelineEventBundle;
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 IntervalBackedDriverTimelineEventBuilderTest {
private final IntervalBackedDriverTimelineEventBuilder builder = new IntervalBackedDriverTimelineEventBuilder(
new DriverTimelineBuilder(),
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
);
@Test
void buildsLifecycleActivityEventsFromIntervals() {
DriverExtractionSession driver = 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(),
List.of()
);
TachographFileSession session = session(driver, true);
TachographTimelineEventBundle bundle = builder.buildEventBundle(session, driver);
assertThat(bundle.activityEvents()).hasSize(2);
assertThat(bundle.activityEvents().get(0).eventDomain()).isEqualTo(EventDomain.DRIVER_ACTIVITY);
assertThat(bundle.activityEvents().get(0).eventType()).isEqualTo(EventType.DRIVE);
assertThat(bundle.activityEvents().get(0).lifecycle()).isEqualTo(EventLifecycle.START);
assertThat(bundle.activityEvents().get(0).occurredAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:30:00Z"));
assertThat(bundle.activityEvents().get(0).driverRef().sourceEntityId()).isEqualTo("DRV:12:123");
assertThat(bundle.activityEvents().get(0).vehicleRef().vin()).isEqualTo("VIN-1");
assertThat(bundle.activityEvents().get(0).eventDetails().type()).isEqualTo("DRIVER_ACTIVITY");
assertThat(bundle.activityEvents().get(0).payload().get("raw").get("intervalId").asText()).isEqualTo("ACT-1");
assertThat(bundle.activityEvents().get(1).lifecycle()).isEqualTo(EventLifecycle.END);
assertThat(bundle.activityEvents().get(1).occurredAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T09:00:00Z"));
}
@Test
void buildsVehicleUsageAndSupportEvents() {
DriverExtractionSession driver = 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(),
List.of(new ExtractedSupportEvent(
"VUGNSS-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()
);
TachographFileSession session = session(driver, false);
TachographTimelineEventBundle bundle = builder.buildEventBundle(session, driver);
assertThat(bundle.vehicleUsageEvents()).hasSize(2);
assertThat(bundle.vehicleUsageEvents().get(0).eventDomain()).isEqualTo(EventDomain.DRIVER_CARD);
assertThat(bundle.vehicleUsageEvents().get(0).eventType()).isEqualTo(EventType.CARD_INSERTED);
assertThat(bundle.vehicleUsageEvents().get(0).lifecycle()).isEqualTo(EventLifecycle.INSERT);
assertThat(bundle.vehicleUsageEvents().get(0).odometerM()).isEqualTo(100_000L);
assertThat(bundle.vehicleUsageEvents().get(1).eventType()).isEqualTo(EventType.CARD_WITHDRAWN);
assertThat(bundle.vehicleUsageEvents().get(1).lifecycle()).isEqualTo(EventLifecycle.WITHDRAW);
assertThat(bundle.vehicleUsageEvents().get(1).odometerM()).isEqualTo(200_000L);
assertThat(bundle.supportEvents()).hasSize(1);
assertThat(bundle.supportEvents().get(0).eventDomain()).isEqualTo(EventDomain.POSITION);
assertThat(bundle.supportEvents().get(0).eventType()).isEqualTo(EventType.POSITION_RECORDED);
assertThat(bundle.supportEvents().get(0).position().latitude()).isEqualTo(BigDecimal.valueOf(48.2082));
assertThat(bundle.supportEvents().get(0).eventDetails().type()).isEqualTo("POSITION");
assertThat(bundle.allEvents()).extracting(event -> event.occurredAt()).isSorted();
}
private TachographFileSession session(DriverExtractionSession driver, boolean driverCardFile) {
return new TachographFileSession(
UUID.randomUUID(),
new TachographFileSessionMetadata(
"default",
"legalrequirements-drivercard",
"sample",
"sample.ddd",
"a",
2,
"42",
"b",
driverCardFile,
null
),
Map.of(driver.driverKey(), driver),
new ExtractionStats(
1,
driver.cardActivityIntervals().size(),
driver.cardVehicleUsageIntervals().size(),
driver.vehicleRegistrations().size(),
driver.vehicles().size(),
0
),
List.of(),
Instant.now(),
Instant.now().plus(4, ChronoUnit.HOURS)
);
}
}

View File

@ -3,6 +3,7 @@ package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.service.EventDetailsFactory;
import at.procon.eventhub.tachographfilesession.dto.TachographEsperEventsProcessingRequest;
import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto;
import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest;
@ -19,6 +20,7 @@ import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class TachographFileSessionProcessingServiceTest {
@ -27,9 +29,19 @@ class TachographFileSessionProcessingServiceTest {
void returnsEsperDriverProcessingResultsFromSessionTimeline() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
DriverTimelineBuilder driverTimelineBuilder = new DriverTimelineBuilder();
TachographFileSessionProcessingService service = new TachographFileSessionProcessingService(
repository,
new DriverTimelineBuilder(),
driverTimelineBuilder,
new DriverTimelineReusableProjectionBuilder(driverTimelineBuilder),
new EventBackedDriverTimelineBuilder(
new IntervalBackedDriverTimelineEventBuilder(
driverTimelineBuilder,
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
)
),
properties
);
@ -99,9 +111,19 @@ class TachographFileSessionProcessingServiceTest {
void appliesOccurredWindowToEsperDriverProcessingResults() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
DriverTimelineBuilder driverTimelineBuilder = new DriverTimelineBuilder();
TachographFileSessionProcessingService service = new TachographFileSessionProcessingService(
repository,
new DriverTimelineBuilder(),
driverTimelineBuilder,
new DriverTimelineReusableProjectionBuilder(driverTimelineBuilder),
new EventBackedDriverTimelineBuilder(
new IntervalBackedDriverTimelineEventBuilder(
driverTimelineBuilder,
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
)
),
properties
);
@ -187,13 +209,109 @@ class TachographFileSessionProcessingServiceTest {
assertThat(result.vuCardAbsentIntervalCount()).isEqualTo(1);
}
@Test
void canUseEventBackedTimelineModeForEsperProcessing() {
EventHubProperties properties = new EventHubProperties();
properties.getTachographFileSession().getProcessing().setTimelineInputMode(EventHubProperties.TimelineInputMode.EVENTS);
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
DriverTimelineBuilder driverTimelineBuilder = new DriverTimelineBuilder();
TachographFileSessionProcessingService service = new TachographFileSessionProcessingService(
repository,
driverTimelineBuilder,
new DriverTimelineReusableProjectionBuilder(driverTimelineBuilder),
new EventBackedDriverTimelineBuilder(
new IntervalBackedDriverTimelineEventBuilder(
driverTimelineBuilder,
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
)
),
properties
);
DriverExtractionSession driver = new DriverExtractionSession(
"12:123",
null,
null,
List.of(),
List.of(),
List.of(
new ExtractedCardVehicleUsageInterval(
"CVU-1",
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T11:00:00Z"),
100L,
200L,
"12:REG-1",
"VIN-1",
"vu-1"
),
new ExtractedCardVehicleUsageInterval(
"CVU-2",
OffsetDateTime.parse("2026-05-01T12:00:00Z"),
OffsetDateTime.parse("2026-05-01T13:00:00Z"),
201L,
260L,
"12:REG-2",
"VIN-2",
"vu-2"
)
),
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"),
new ExtractedCardActivityInterval("ACT-2", OffsetDateTime.parse("2026-05-01T09:00:00Z"), OffsetDateTime.parse("2026-05-01T10:00:00Z"), "WORK", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "b"),
new ExtractedCardActivityInterval("ACT-3", OffsetDateTime.parse("2026-05-01T10:00:00Z"), OffsetDateTime.parse("2026-05-01T10:05:00Z"), "DRIVE", "DRIVER", "INSERTED", "SINGLE", "12:REG-2", "VIN-2", "c")
),
List.of(),
List.of()
);
TachographFileSession session = new TachographFileSession(
UUID.randomUUID(),
new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null),
Map.of(driver.driverKey(), driver),
new ExtractionStats(1, 2, 2, 1, 1, 0),
List.of(),
Instant.now(),
Instant.now().plus(4, ChronoUnit.HOURS)
);
repository.save(session);
TachographEsperDriverProcessingResultDto result = service.getEsperDriverProcessingResults(
session.sessionId(),
driver.driverKey(),
new TachographEsperEventsProcessingRequest(
OffsetDateTime.parse("2026-05-01T08:45:00Z"),
OffsetDateTime.parse("2026-05-01T12:30:00Z"),
3,
720
)
);
assertThat(result.activityIntervalCount()).isEqualTo(3);
assertThat(result.drivingIntervalCount()).isEqualTo(2);
assertThat(result.drivingInterruptionIntervalCount()).isEqualTo(1);
assertThat(result.vehicleUsageIntervalCount()).isEqualTo(2);
assertThat(result.vuCardAbsentIntervalCount()).isEqualTo(1);
}
@Test
void returnsPotentialHomeOvernightStayIntervalsWhenVuCardAbsentCoversLongDti() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
DriverTimelineBuilder driverTimelineBuilder = new DriverTimelineBuilder();
TachographFileSessionProcessingService service = new TachographFileSessionProcessingService(
repository,
new DriverTimelineBuilder(),
driverTimelineBuilder,
new DriverTimelineReusableProjectionBuilder(driverTimelineBuilder),
new EventBackedDriverTimelineBuilder(
new IntervalBackedDriverTimelineEventBuilder(
driverTimelineBuilder,
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
)
),
properties
);
@ -272,9 +390,19 @@ class TachographFileSessionProcessingServiceTest {
void evaluatesOperatingPeriodsFromSessionTimeline() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
DriverTimelineBuilder driverTimelineBuilder = new DriverTimelineBuilder();
TachographFileSessionProcessingService service = new TachographFileSessionProcessingService(
repository,
new DriverTimelineBuilder(),
driverTimelineBuilder,
new DriverTimelineReusableProjectionBuilder(driverTimelineBuilder),
new EventBackedDriverTimelineBuilder(
new IntervalBackedDriverTimelineEventBuilder(
driverTimelineBuilder,
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
)
),
properties
);

View File

@ -65,10 +65,17 @@ class VehicleUnitXmlExtractionServiceTest {
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to()).isNull();
assertThat(secondDriver.cardActivityIntervals()).hasSize(1);
assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z");
assertThat(secondDriver.supportEvents()).hasSize(2);
assertThat(secondDriver.supportEvents()).extracting("eventDomain").containsExactly("POSITION", "SPECIFIC_CONDITION");
assertThat(secondDriver.supportEvents()).hasSize(6);
assertThat(secondDriver.supportEvents()).extracting("eventDomain")
.containsExactly("POSITION", "SPECIFIC_CONDITION", "BORDER_CROSSING", "LOAD_UNLOAD", "SPEEDING", "SPEEDING");
assertThat(secondDriver.supportEvents().get(0).latitude()).isNotNull();
assertThat(secondDriver.supportEvents().get(1).code()).isEqualTo("1");
assertThat(secondDriver.supportEvents().get(1).eventType()).isEqualTo("OUT");
assertThat(secondDriver.supportEvents().get(1).eventLifecycle()).isEqualTo("BEGIN");
assertThat(secondDriver.supportEvents().get(2).countryFrom()).isEqualTo("12");
assertThat(secondDriver.supportEvents().get(2).countryTo()).isEqualTo("56");
assertThat(secondDriver.supportEvents().get(3).operation()).isEqualTo("LOAD");
assertThat(secondDriver.supportEvents().get(4).eventLifecycle()).isEqualTo("BEGIN");
assertThat(secondDriver.supportEvents().get(5).eventLifecycle()).isEqualTo("END");
assertThat(session.warnings()).isEmpty();
}

View File

@ -169,7 +169,80 @@ final class VehicleUnitXmlSamples {
<specificConditionType>1</specificConditionType>
</specificConditionRecords>
</vuSpecificConditionData>
<vuBorderCrossingData>
<vuBorderCrossingRecord>
<cardNumberDriverSlot>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>999999999999</driverIdentification>
<cardReplacementIndex>1</cardReplacementIndex>
<cardRenewalIndex>1</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</cardNumberDriverSlot>
<countryLeft>12</countryLeft>
<countryEntered>56</countryEntered>
<gnssPlaceAuthRecord>
<timeStamp>2026-04-02T08:30:00Z</timeStamp>
<geoCoordinates>
<latitude>48020</latitude>
<longitude>16380</longitude>
</geoCoordinates>
<authenticationStatus>AUTHENTICATED</authenticationStatus>
</gnssPlaceAuthRecord>
<vehicleOdometerValue>1220</vehicleOdometerValue>
</vuBorderCrossingRecord>
</vuBorderCrossingData>
<vuLoadUnloadData>
<vuLoadUnloadRecord>
<timeStamp>2026-04-02T08:45:00Z</timeStamp>
<operationType>1</operationType>
<cardNumberDriverSlot>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>999999999999</driverIdentification>
<cardReplacementIndex>1</cardReplacementIndex>
<cardRenewalIndex>1</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</cardNumberDriverSlot>
<gnssPlaceAuthRecord>
<timeStamp>2026-04-02T08:45:00Z</timeStamp>
<geoCoordinates>
<latitude>48025</latitude>
<longitude>16385</longitude>
</geoCoordinates>
<authenticationStatus>AUTHENTICATED</authenticationStatus>
</gnssPlaceAuthRecord>
<vehicleOdometerValue>1225</vehicleOdometerValue>
</vuLoadUnloadRecord>
</vuLoadUnloadData>
</Activities>
<EventsFaults>
<vuOverSpeedingEventData>
<vuOverSpeedingEventRecords>
<eventType>1</eventType>
<eventRecordPurpose>1</eventRecordPurpose>
<eventBeginTime>2026-04-02T08:10:00Z</eventBeginTime>
<eventEndTime>2026-04-02T08:12:00Z</eventEndTime>
<maxSpeedValue>96</maxSpeedValue>
<averageSpeedValue>91</averageSpeedValue>
<cardNumberDriverSlotBegin>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>999999999999</driverIdentification>
<cardReplacementIndex>1</cardReplacementIndex>
<cardRenewalIndex>1</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</cardNumberDriverSlotBegin>
<similarEventsNumber>1</similarEventsNumber>
</vuOverSpeedingEventRecords>
</vuOverSpeedingEventData>
</EventsFaults>
</VehicleUnit>
""";
}