Add JDBC tachograph activity extraction

This commit is contained in:
trifonovt 2026-04-30 13:03:20 +02:00
parent 21a4fe12fb
commit 29ba656ed2
20 changed files with 881 additions and 0 deletions

View File

@ -806,3 +806,23 @@ Replace `NoopTachographExtractionBatchExecutor` with an implementation that:
``` ```
The import cursor is advanced only when the executor reports `executed=true`. The default no-op executor returns `executed=false`, so it does not move cursors accidentally. The import cursor is advanced only when the executor reports `executed=true`. The default no-op executor returns `executed=false`, so it does not move cursors accidentally.
## JDBC tachograph extraction
The first concrete extractor is `JdbcTachographExtractionBatchExecutor`. It is enabled only when `eventhub.tachograph.datasource.jdbc-url` is configured. Without that datasource, the application keeps using `NoopTachographExtractionBatchExecutor`.
Currently implemented extraction definitions:
```text
CARD_ACTIVITY -> DRIVER_ACTIVITY / DRIVER_CARD / CardActivity
VU_ACTIVITY -> DRIVER_ACTIVITY / VEHICLE_UNIT / VUActivity
```
SQL resources:
```text
src/main/resources/sql/tachograph/card-activity.sql
src/main/resources/sql/tachograph/vu-activity.sql
```
These files are the schema-specific layer. They must keep the documented column aliases because the row mappers consume those aliases to build `EventHubEventDto`, including `sourcePackageRef`, driver/vehicle references, event details and raw payload.

View File

@ -82,6 +82,11 @@
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
<scope>runtime</scope>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@ -72,6 +72,9 @@ public class EventHubProperties {
/** Configured tenant/source import plans. */ /** Configured tenant/source import plans. */
private List<ConfiguredImportPlan> importPlans = new ArrayList<>(); private List<ConfiguredImportPlan> importPlans = new ArrayList<>();
/** Optional external tachograph DB datasource. If absent, the no-op extractor is used. */
private TachographDataSource datasource = new TachographDataSource();
public int getDefaultChunkDays() { public int getDefaultChunkDays() {
return defaultChunkDays; return defaultChunkDays;
} }
@ -119,6 +122,53 @@ public class EventHubProperties {
public void setImportPlans(List<ConfiguredImportPlan> importPlans) { public void setImportPlans(List<ConfiguredImportPlan> importPlans) {
this.importPlans = importPlans == null ? new ArrayList<>() : importPlans; this.importPlans = importPlans == null ? new ArrayList<>() : importPlans;
} }
public TachographDataSource getDatasource() {
return datasource;
}
public void setDatasource(TachographDataSource datasource) {
this.datasource = datasource == null ? new TachographDataSource() : datasource;
}
}
public static class TachographDataSource {
private String jdbcUrl;
private String username;
private String password;
private String driverClassName;
public String getJdbcUrl() {
return jdbcUrl;
}
public void setJdbcUrl(String jdbcUrl) {
this.jdbcUrl = jdbcUrl;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getDriverClassName() {
return driverClassName;
}
public void setDriverClassName(String driverClassName) {
this.driverClassName = driverClassName;
}
} }
public static class ConfiguredImportPlan { public static class ConfiguredImportPlan {

View File

@ -0,0 +1,34 @@
package at.procon.eventhub.config;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
@Configuration
@ConditionalOnExpression("'${eventhub.tachograph.datasource.jdbc-url:}' != ''")
public class TachographDataSourceConfig {
@Bean
public DataSource tachographDataSource(EventHubProperties properties) {
EventHubProperties.TachographDataSource config = properties.getTachograph().getDatasource();
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setUrl(config.getJdbcUrl());
dataSource.setUsername(config.getUsername());
dataSource.setPassword(config.getPassword());
if (config.getDriverClassName() != null && !config.getDriverClassName().isBlank()) {
dataSource.setDriverClassName(config.getDriverClassName());
}
return dataSource;
}
@Bean
public NamedParameterJdbcTemplate tachographNamedParameterJdbcTemplate(
@Qualifier("tachographDataSource") DataSource tachographDataSource
) {
return new NamedParameterJdbcTemplate(tachographDataSource);
}
}

View File

@ -0,0 +1,11 @@
package at.procon.eventhub.dto;
import java.time.OffsetDateTime;
public record ImportCursorStateDto(
OffsetDateTime lastSourcePackageImportedAt,
String lastSourcePackageId,
OffsetDateTime lastSourceRowUpdatedAt,
OffsetDateTime lastOccurredTo
) {
}

View File

@ -2,6 +2,7 @@ package at.procon.eventhub.persistence;
import at.procon.eventhub.dto.AcquisitionStrategy; import at.procon.eventhub.dto.AcquisitionStrategy;
import at.procon.eventhub.dto.EventFamily; import at.procon.eventhub.dto.EventFamily;
import at.procon.eventhub.dto.ImportCursorStateDto;
import at.procon.eventhub.dto.TachographExtractionBatchResultDto; import at.procon.eventhub.dto.TachographExtractionBatchResultDto;
import java.util.UUID; import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@ -16,6 +17,45 @@ public class ImportCursorRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
public ImportCursorStateDto findCursor(
String tenantKey,
int eventSourceId,
String scopeHash,
EventFamily eventFamily,
String sourceKind,
AcquisitionStrategy strategy
) {
return jdbcTemplate.query(
"""
select last_source_package_imported_at,
last_source_package_id,
last_source_row_updated_at,
last_occurred_to
from eventhub.import_cursor
where tenant_key = ?
and event_source_id = ?
and scope_hash = ?
and event_family = ?
and source_kind = ?
and cursor_type = ?
""",
rs -> rs.next()
? new ImportCursorStateDto(
rs.getObject("last_source_package_imported_at", java.time.OffsetDateTime.class),
rs.getString("last_source_package_id"),
rs.getObject("last_source_row_updated_at", java.time.OffsetDateTime.class),
rs.getObject("last_occurred_to", java.time.OffsetDateTime.class)
)
: null,
tenantKey,
eventSourceId,
scopeHash,
eventFamily.name(),
sourceKind,
strategy.name()
);
}
public void advanceCursor( public void advanceCursor(
String tenantKey, String tenantKey,
int eventSourceId, int eventSourceId,

View File

@ -0,0 +1,293 @@
package at.procon.eventhub.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.EventDomain;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
import at.procon.eventhub.dto.SourcePackageRefDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
abstract class AbstractTachographActivityRowMapper implements TachographExtractionRowMapper {
private final EventDetailsFactory detailsFactory;
protected AbstractTachographActivityRowMapper(EventDetailsFactory detailsFactory) {
this.detailsFactory = detailsFactory;
}
@Override
public EventHubEventDto map(ResultSet rs, int rowNum, TachographExtractionContext context) throws SQLException {
OffsetDateTime occurredAt = offsetDateTime(rs, "occurred_at");
SourcePackageRefDto sourcePackageRef = sourcePackageRef(rs);
DriverRefDto driverRef = driverRef(rs);
VehicleRefDto vehicleRef = vehicleRef(rs);
String externalSourceEventId = string(rs, "external_source_event_id");
if (externalSourceEventId == null) {
externalSourceEventId = defaultExternalSourceEventId(context, rowNum, occurredAt, sourcePackageRef, driverRef, vehicleRef);
}
return new EventHubEventDto(
UUID.randomUUID(),
externalSourceEventId,
driverRef,
vehicleRef,
occurredAt,
offsetDateTime(rs, "received_partner_at"),
OffsetDateTime.now(),
EventDomain.DRIVER_ACTIVITY,
eventType(rs),
lifecycle(rs),
longValue(rs, "odometer_m"),
null,
detailsFactory.driverActivity(cardSlot(rs), cardStatus(rs), drivingStatus(rs)),
sourcePackageRef != null && sourcePackageRef.hasAnyReference() ? sourcePackageRef : null,
detailsFactory.payloadFromMap(payload(rs, context)),
false,
context.packageInfo()
);
}
protected abstract Map<String, Object> sourceSpecificPayload(ResultSet rs) throws SQLException;
private DriverRefDto driverRef(ResultSet rs) throws SQLException {
DriverCardRefDto driverCard = null;
String cardNumber = string(rs, "driver_card_number");
if (cardNumber != null) {
driverCard = new DriverCardRefDto(string(rs, "driver_card_nation"), cardNumber);
}
DriverRefDto driverRef = new DriverRefDto(string(rs, "driver_source_entity_id"), driverCard);
return driverRef.hasAnyReference() ? driverRef : null;
}
private VehicleRefDto vehicleRef(ResultSet rs) throws SQLException {
VehicleRegistrationRefDto registration = null;
String registrationNumber = string(rs, "vehicle_registration_number");
if (registrationNumber != null) {
registration = new VehicleRegistrationRefDto(string(rs, "vehicle_registration_nation"), registrationNumber);
}
VehicleRefDto vehicleRef = new VehicleRefDto(string(rs, "vehicle_source_entity_id"), string(rs, "vehicle_vin"), registration);
return vehicleRef.hasAnyReference() ? vehicleRef : null;
}
private SourcePackageRefDto sourcePackageRef(ResultSet rs) throws SQLException {
return new SourcePackageRefDto(
string(rs, "source_package_kind"),
string(rs, "source_package_id"),
string(rs, "source_package_entity_id"),
offsetDateTime(rs, "source_package_period_from"),
offsetDateTime(rs, "source_package_period_to"),
offsetDateTime(rs, "source_package_imported_at")
);
}
private Map<String, Object> payload(ResultSet rs, TachographExtractionContext context) throws SQLException {
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("extractionCode", context.planItem().extractionCode());
raw.put("sourceKind", context.planItem().sourceKind());
raw.put("sourceTables", context.planItem().sourceTables());
put(raw, "sourceRowId", string(rs, "source_row_id"));
put(raw, "activityCode", object(rs, "activity_code"));
put(raw, "activityText", string(rs, "activity_text"));
put(raw, "eventType", string(rs, "event_type"));
put(raw, "lifecycle", string(rs, "lifecycle"));
put(raw, "cardSlot", object(rs, "card_slot"));
put(raw, "cardStatus", object(rs, "card_status"));
put(raw, "drivingStatus", object(rs, "driving_status"));
put(raw, "driverSourceEntityId", string(rs, "driver_source_entity_id"));
put(raw, "driverCardNation", string(rs, "driver_card_nation"));
put(raw, "driverCardNumber", string(rs, "driver_card_number"));
put(raw, "vehicleSourceEntityId", string(rs, "vehicle_source_entity_id"));
put(raw, "vehicleVin", string(rs, "vehicle_vin"));
put(raw, "vehicleRegistrationNation", string(rs, "vehicle_registration_nation"));
put(raw, "vehicleRegistrationNumber", string(rs, "vehicle_registration_number"));
raw.putAll(sourceSpecificPayload(rs));
return Map.of("raw", raw);
}
private String defaultExternalSourceEventId(
TachographExtractionContext context,
int rowNum,
OffsetDateTime occurredAt,
SourcePackageRefDto sourcePackageRef,
DriverRefDto driverRef,
VehicleRefDto vehicleRef
) {
String sourcePackageId = sourcePackageRef == null || sourcePackageRef.sourcePackageId() == null
? "NO_SOURCE_PACKAGE"
: sourcePackageRef.sourcePackageId();
String subject = driverRef != null && driverRef.hasAnyReference()
? driverRef.stableKey()
: vehicleRef == null ? "NO_SUBJECT" : vehicleRef.stableKey();
return "TACHOGRAPH:" + context.planItem().extractionCode()
+ ":" + sourcePackageId
+ ":" + occurredAt
+ ":" + subject
+ ":ROW-" + rowNum;
}
protected String string(ResultSet rs, String column) throws SQLException {
String value = rs.getString(column);
return value == null || value.isBlank() ? null : value.trim();
}
protected Object object(ResultSet rs, String column) throws SQLException {
return rs.getObject(column);
}
protected OffsetDateTime offsetDateTime(ResultSet rs, String column) throws SQLException {
Object value = rs.getObject(column);
if (value == null) {
return null;
}
if (value instanceof OffsetDateTime offsetDateTime) {
return offsetDateTime;
}
if (value instanceof Timestamp timestamp) {
return timestamp.toInstant().atOffset(ZoneOffset.UTC);
}
if (value instanceof java.time.LocalDateTime localDateTime) {
return localDateTime.atOffset(ZoneOffset.UTC);
}
return OffsetDateTime.parse(value.toString());
}
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 EventType eventType(ResultSet rs) throws SQLException {
String eventType = string(rs, "event_type");
if (eventType != null) {
return parseEnum(EventType.class, eventType, EventType.UNKNOWN_ACTIVITY);
}
return switch (integer(rs, "activity_code", -1)) {
case 0 -> EventType.BREAK_REST;
case 1 -> EventType.AVAILABILITY;
case 2 -> EventType.WORK;
case 3 -> EventType.DRIVE;
default -> EventType.UNKNOWN_ACTIVITY;
};
}
private EventLifecycle lifecycle(ResultSet rs) throws SQLException {
return parseEnum(EventLifecycle.class, string(rs, "lifecycle"), EventLifecycle.SNAPSHOT);
}
private CardSlot cardSlot(ResultSet rs) throws SQLException {
Object value = object(rs, "card_slot");
if (value instanceof Number number) {
return switch (number.intValue()) {
case 0 -> CardSlot.DRIVER;
case 1 -> CardSlot.CO_DRIVER;
default -> null;
};
}
String text = value == null ? null : value.toString();
Integer parsed = parseInteger(text);
if (parsed != null) {
return parsed == 0 ? CardSlot.DRIVER : parsed == 1 ? CardSlot.CO_DRIVER : null;
}
return parseEnum(CardSlot.class, text, null);
}
private CardStatus cardStatus(ResultSet rs) throws SQLException {
Object value = object(rs, "card_status");
if (value instanceof Number number) {
return switch (number.intValue()) {
case 0 -> CardStatus.INSERTED;
case 1 -> CardStatus.NOT_INSERTED;
default -> null;
};
}
String text = value == null ? null : value.toString();
Integer parsed = parseInteger(text);
if (parsed != null) {
return parsed == 0 ? CardStatus.INSERTED : parsed == 1 ? CardStatus.NOT_INSERTED : null;
}
return parseEnum(CardStatus.class, text, null);
}
private DrivingStatus drivingStatus(ResultSet rs) throws SQLException {
Object value = object(rs, "driving_status");
if (value instanceof Number number) {
return switch (number.intValue()) {
case 0 -> DrivingStatus.SINGLE;
case 1 -> DrivingStatus.CREW;
default -> DrivingStatus.UNKNOWN;
};
}
String text = value == null ? null : value.toString();
Integer parsed = parseInteger(text);
if (parsed != null) {
return parsed == 0 ? DrivingStatus.SINGLE : parsed == 1 ? DrivingStatus.CREW : DrivingStatus.UNKNOWN;
}
return parseEnum(DrivingStatus.class, text, DrivingStatus.UNKNOWN);
}
private int integer(ResultSet rs, String column, int fallback) throws SQLException {
Object value = rs.getObject(column);
if (value == null) {
return fallback;
}
if (value instanceof Number number) {
return number.intValue();
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException ignored) {
return fallback;
}
}
private <T extends Enum<T>> T parseEnum(Class<T> type, String value, T fallback) {
if (value == null) {
return fallback;
}
String normalized = value.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_');
try {
return Enum.valueOf(type, normalized);
} catch (IllegalArgumentException ignored) {
return fallback;
}
}
private Integer parseInteger(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
protected void put(Map<String, Object> target, String key, Object value) {
if (value != null) {
target.put(key, value);
}
}
}

View File

@ -0,0 +1,22 @@
package at.procon.eventhub.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class CardActivityRowMapper extends AbstractTachographActivityRowMapper {
public CardActivityRowMapper(EventDetailsFactory detailsFactory) {
super(detailsFactory);
}
@Override
protected Map<String, Object> sourceSpecificPayload(ResultSet rs) throws SQLException {
Map<String, Object> raw = new LinkedHashMap<>();
put(raw, "cardActivityId", string(rs, "card_activity_id"));
return raw;
}
}

View File

@ -0,0 +1,177 @@
package at.procon.eventhub.service;
import at.procon.eventhub.dto.ImportCursorStateDto;
import at.procon.eventhub.dto.ImportScopeDto;
import at.procon.eventhub.dto.TachographExtractionBatchResultDto;
import at.procon.eventhub.dto.TachographImportPlanItemDto;
import at.procon.eventhub.dto.TachographImportRequest;
import at.procon.eventhub.dto.TimeChunkDto;
import at.procon.eventhub.persistence.ImportCursorRepository;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.apache.camel.ProducerTemplate;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;
@Service
@ConditionalOnBean(name = "tachographNamedParameterJdbcTemplate")
@ConditionalOnExpression("'${eventhub.tachograph.datasource.jdbc-url:}' != ''")
public class JdbcTachographExtractionBatchExecutor implements TachographExtractionBatchExecutor {
private final NamedParameterJdbcTemplate tachographJdbcTemplate;
private final ProducerTemplate producerTemplate;
private final ResourceLoader resourceLoader;
private final TachographExtractionDefinitionRegistry definitionRegistry;
private final ImportCursorRepository importCursorRepository;
public JdbcTachographExtractionBatchExecutor(
@Qualifier("tachographNamedParameterJdbcTemplate") NamedParameterJdbcTemplate tachographJdbcTemplate,
ProducerTemplate producerTemplate,
ResourceLoader resourceLoader,
TachographExtractionDefinitionRegistry definitionRegistry,
ImportCursorRepository importCursorRepository
) {
this.tachographJdbcTemplate = tachographJdbcTemplate;
this.producerTemplate = producerTemplate;
this.resourceLoader = resourceLoader;
this.definitionRegistry = definitionRegistry;
this.importCursorRepository = importCursorRepository;
}
@Override
public TachographExtractionBatchResultDto execute(
UUID importRunId,
UUID packageId,
int eventSourceId,
TachographImportRequest request,
TachographImportPlanItemDto planItem,
TimeChunkDto chunk
) {
TachographExtractionDefinition definition = definitionRegistry.findByCode(planItem.extractionCode())
.orElseThrow(() -> new IllegalArgumentException("No tachograph extraction definition for " + planItem.extractionCode()));
ImportScopeDto chunkScope = chunkScope(request.importScope(), chunk);
var packageInfo = new at.procon.eventhub.dto.EventHubPackageRequest(
request.tenantKey(),
eventSourceFor(request, planItem),
request.sourceGroup(),
chunkScope,
planItem.eventFamily().name(),
chunk.occurredFrom() == null ? null : chunk.occurredFrom().toLocalDate(),
"TACHOGRAPH:" + planItem.sourceKind() + ":" + planItem.extractionCode() + ":RUN-" + importRunId + ":CHUNK-" + chunk.sequence()
);
TachographExtractionContext context = new TachographExtractionContext(
importRunId,
packageId,
eventSourceId,
request,
planItem,
chunk,
packageInfo.eventSource(),
packageInfo
);
String scopeHash = request.importScope() == null ? "NO_SCOPE" : request.importScope().stableKey();
ImportCursorStateDto cursor = importCursorRepository.findCursor(
request.tenantKey(),
eventSourceId,
scopeHash,
planItem.eventFamily(),
planItem.sourceKind(),
request.acquisitionStrategy()
);
Map<String, Object> params = parameters(request, chunkScope, cursor);
String sql = loadSql(definition.sqlResource());
var events = tachographJdbcTemplate.query(sql, params, (rs, rowNum) -> definition.rowMapper().map(rs, rowNum, context));
events.forEach(event -> producerTemplate.sendBody("direct:eventhub-normalized-input", event));
OffsetDateTime lastSourcePackageImportedAt = events.stream()
.map(event -> event.sourcePackageRef() == null ? null : event.sourcePackageRef().importedIntoSourceAt())
.filter(value -> value != null)
.max(OffsetDateTime::compareTo)
.orElse(cursor == null ? null : cursor.lastSourcePackageImportedAt());
String lastSourcePackageId = events.stream()
.filter(event -> event.sourcePackageRef() != null && event.sourcePackageRef().importedIntoSourceAt() != null)
.max((left, right) -> left.sourcePackageRef().importedIntoSourceAt().compareTo(right.sourcePackageRef().importedIntoSourceAt()))
.map(event -> event.sourcePackageRef().sourcePackageId())
.orElse(cursor == null ? null : cursor.lastSourcePackageId());
return new TachographExtractionBatchResultDto(
packageId,
planItem.extractionCode(),
planItem.sourceKind(),
events.size(),
events.size(),
events.size(),
0,
true,
lastSourcePackageImportedAt,
lastSourcePackageId,
null,
chunk.occurredTo()
);
}
private Map<String, Object> parameters(TachographImportRequest request, ImportScopeDto scope, ImportCursorStateDto cursor) {
Map<String, Object> params = new HashMap<>();
params.put("tenantKey", request.tenantKey());
params.put("occurredFrom", scope == null ? null : scope.occurredFrom());
params.put("occurredTo", scope == null ? null : scope.occurredTo());
params.put("rootOrganisationId", scope == null || scope.rootSourceOrganisation() == null ? null : scope.rootSourceOrganisation().sourceEntityId());
params.put("includeChildren", scope != null && scope.includeChildren());
params.put("lastSourcePackageImportedAt", cursor == null ? null : cursor.lastSourcePackageImportedAt());
params.put("lastSourcePackageId", cursor == null ? null : cursor.lastSourcePackageId());
params.put("lastSourceRowUpdatedAt", cursor == null ? null : cursor.lastSourceRowUpdatedAt());
params.put("lastOccurredTo", cursor == null ? null : cursor.lastOccurredTo());
return params;
}
private ImportScopeDto chunkScope(ImportScopeDto scope, TimeChunkDto chunk) {
if (scope == null) {
return ImportScopeDto.tenantAll(chunk.occurredFrom(), chunk.occurredTo());
}
return new ImportScopeDto(
scope.type(),
scope.rootSourceOrganisation(),
scope.includeChildren(),
chunk.occurredFrom(),
chunk.occurredTo()
);
}
private at.procon.eventhub.dto.EventSourceDto eventSourceFor(TachographImportRequest request, TachographImportPlanItemDto planItem) {
String sourceKey = switch (planItem.sourceKind()) {
case "VEHICLE_UNIT" -> "TACHOGRAPH_VEHICLE_UNIT";
case "DRIVER_CARD" -> "TACHOGRAPH_DRIVER_CARD";
default -> request.eventSource().sourceKey();
};
return new at.procon.eventhub.dto.EventSourceDto(
request.eventSource().providerKey(),
planItem.sourceKind(),
sourceKey,
request.eventSource().sourceInstanceKey(),
request.eventSource().tenantProviderSettingKey(),
request.eventSource().externalFleetKey()
);
}
private String loadSql(String location) {
Resource resource = resourceLoader.getResource(location);
try (var inputStream = resource.getInputStream()) {
return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("Cannot load tachograph extraction SQL resource " + location, e);
}
}
}

View File

@ -5,6 +5,8 @@ import at.procon.eventhub.dto.TachographImportPlanItemDto;
import at.procon.eventhub.dto.TachographImportRequest; import at.procon.eventhub.dto.TachographImportRequest;
import at.procon.eventhub.dto.TimeChunkDto; import at.procon.eventhub.dto.TimeChunkDto;
import java.util.UUID; import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -17,6 +19,8 @@ import org.springframework.stereotype.Service;
* direct:eventhub-normalized-input. * direct:eventhub-normalized-input.
*/ */
@Service @Service
@ConditionalOnMissingBean(TachographExtractionBatchExecutor.class)
@ConditionalOnExpression("'${eventhub.tachograph.datasource.jdbc-url:}' == ''")
public class NoopTachographExtractionBatchExecutor implements TachographExtractionBatchExecutor { public class NoopTachographExtractionBatchExecutor implements TachographExtractionBatchExecutor {
private static final Logger log = LoggerFactory.getLogger(NoopTachographExtractionBatchExecutor.class); private static final Logger log = LoggerFactory.getLogger(NoopTachographExtractionBatchExecutor.class);
@ -25,6 +29,7 @@ public class NoopTachographExtractionBatchExecutor implements TachographExtracti
public TachographExtractionBatchResultDto execute( public TachographExtractionBatchResultDto execute(
UUID importRunId, UUID importRunId,
UUID packageId, UUID packageId,
int eventSourceId,
TachographImportRequest request, TachographImportRequest request,
TachographImportPlanItemDto planItem, TachographImportPlanItemDto planItem,
TimeChunkDto chunk TimeChunkDto chunk

View File

@ -11,6 +11,7 @@ public interface TachographExtractionBatchExecutor {
TachographExtractionBatchResultDto execute( TachographExtractionBatchResultDto execute(
UUID importRunId, UUID importRunId,
UUID packageId, UUID packageId,
int eventSourceId,
TachographImportRequest request, TachographImportRequest request,
TachographImportPlanItemDto planItem, TachographImportPlanItemDto planItem,
TimeChunkDto chunk TimeChunkDto chunk

View File

@ -0,0 +1,20 @@
package at.procon.eventhub.service;
import at.procon.eventhub.dto.EventHubPackageRequest;
import at.procon.eventhub.dto.EventSourceDto;
import at.procon.eventhub.dto.TachographImportPlanItemDto;
import at.procon.eventhub.dto.TachographImportRequest;
import at.procon.eventhub.dto.TimeChunkDto;
import java.util.UUID;
public record TachographExtractionContext(
UUID importRunId,
UUID packageId,
int eventSourceId,
TachographImportRequest request,
TachographImportPlanItemDto planItem,
TimeChunkDto chunk,
EventSourceDto eventSource,
EventHubPackageRequest packageInfo
) {
}

View File

@ -0,0 +1,13 @@
package at.procon.eventhub.service;
import at.procon.eventhub.dto.EventFamily;
public record TachographExtractionDefinition(
String code,
EventFamily eventFamily,
String sourceKind,
String entityAxis,
String sqlResource,
TachographExtractionRowMapper rowMapper
) {
}

View File

@ -0,0 +1,50 @@
package at.procon.eventhub.service;
import at.procon.eventhub.dto.EventFamily;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;
@Component
public class TachographExtractionDefinitionRegistry {
private final Map<String, TachographExtractionDefinition> definitionsByCode;
public TachographExtractionDefinitionRegistry(
CardActivityRowMapper cardActivityRowMapper,
VuActivityRowMapper vuActivityRowMapper
) {
List<TachographExtractionDefinition> definitions = List.of(
new TachographExtractionDefinition(
"CARD_ACTIVITY",
EventFamily.DRIVER_ACTIVITY,
"DRIVER_CARD",
"DRIVER",
"classpath:sql/tachograph/card-activity.sql",
cardActivityRowMapper
),
new TachographExtractionDefinition(
"VU_ACTIVITY",
EventFamily.DRIVER_ACTIVITY,
"VEHICLE_UNIT",
"VEHICLE",
"classpath:sql/tachograph/vu-activity.sql",
vuActivityRowMapper
)
);
this.definitionsByCode = definitions.stream()
.collect(Collectors.toUnmodifiableMap(definition -> normalize(definition.code()), Function.identity()));
}
public Optional<TachographExtractionDefinition> findByCode(String code) {
return Optional.ofNullable(definitionsByCode.get(normalize(code)));
}
private String normalize(String value) {
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
}
}

View File

@ -0,0 +1,10 @@
package at.procon.eventhub.service;
import at.procon.eventhub.dto.EventHubEventDto;
import java.sql.ResultSet;
import java.sql.SQLException;
public interface TachographExtractionRowMapper {
EventHubEventDto map(ResultSet rs, int rowNum, TachographExtractionContext context) throws SQLException;
}

View File

@ -132,6 +132,7 @@ public class TachographImportExecutionService {
TachographExtractionBatchResultDto result = extractionBatchExecutor.execute( TachographExtractionBatchResultDto result = extractionBatchExecutor.execute(
importRunId, importRunId,
plannedPackage.packageId(), plannedPackage.packageId(),
plannedPackage.eventSourceId(),
request, request,
plannedPackage.planItem(), plannedPackage.planItem(),
plannedPackage.chunk() plannedPackage.chunk()

View File

@ -0,0 +1,22 @@
package at.procon.eventhub.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class VuActivityRowMapper extends AbstractTachographActivityRowMapper {
public VuActivityRowMapper(EventDetailsFactory detailsFactory) {
super(detailsFactory);
}
@Override
protected Map<String, Object> sourceSpecificPayload(ResultSet rs) throws SQLException {
Map<String, Object> raw = new LinkedHashMap<>();
put(raw, "vuActivityId", string(rs, "vu_activity_id"));
return raw;
}
}

View File

@ -33,6 +33,13 @@ eventhub:
default-chunk-days: 1 default-chunk-days: 1
occurred-at-overlap: 7d occurred-at-overlap: 7d
# Configure this block to enable JdbcTachographExtractionBatchExecutor.
# datasource:
# jdbc-url: jdbc:sqlserver://localhost:1433;databaseName=tachograph;encrypt=true;trustServerCertificate=true
# username: tachograph_user
# password: change-me
# driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
# Enables the scheduler that regularly triggers configured tachograph import plans. # Enables the scheduler that regularly triggers configured tachograph import plans.
scheduler-enabled: false scheduler-enabled: false
scheduler-poll-interval-ms: 60000 scheduler-poll-interval-ms: 60000

View File

@ -0,0 +1,50 @@
/*
* CardActivity DRIVER_ACTIVITY extraction.
*
* Adapt table and column names to the concrete tachograph SQL Server schema.
* Keep the selected aliases stable; CardActivityRowMapper consumes this alias contract.
*/
select
cast(ca.Id as varchar(128)) as source_row_id,
cast(ca.Id as varchar(128)) as card_activity_id,
concat('TACHOGRAPH:CARD_ACTIVITY:', ca.Id) as external_source_event_id,
ca.ActivityTime as occurred_at,
ca.ReceivedAt as received_partner_at,
ca.Activity as activity_code,
ca.ActivityText as activity_text,
ca.EventType as event_type,
ca.Lifecycle as lifecycle,
ca.CardSlot as card_slot,
ca.CardStatus as card_status,
ca.DrivingStatus as driving_status,
ca.OdometerM as odometer_m,
cast(ca.DriverId as varchar(128)) as driver_source_entity_id,
ca.DriverCardNation as driver_card_nation,
ca.DriverCardNumber as driver_card_number,
cast(ca.VehicleId as varchar(128)) as vehicle_source_entity_id,
ca.VehicleVin as vehicle_vin,
ca.VehicleRegistrationNation as vehicle_registration_nation,
ca.VehicleRegistrationNumber as vehicle_registration_number,
'DRIVER_CARD' as source_package_kind,
cast(ca.SourcePackageId as varchar(128)) as source_package_id,
cast(ca.DriverId as varchar(128)) as source_package_entity_id,
ca.SourcePackagePeriodFrom as source_package_period_from,
ca.SourcePackagePeriodTo as source_package_period_to,
ca.SourcePackageImportedAt as source_package_imported_at
from CardActivity ca
where (:occurredFrom is null or ca.ActivityTime >= :occurredFrom)
and (:occurredTo is null or ca.ActivityTime < :occurredTo)
and (
:lastSourcePackageImportedAt is null
or ca.SourcePackageImportedAt > :lastSourcePackageImportedAt
or (ca.SourcePackageImportedAt = :lastSourcePackageImportedAt and cast(ca.SourcePackageId as varchar(128)) > :lastSourcePackageId)
)
/*
* Organisation filtering is schema-specific. Once stable joins are known, add:
* and (:rootOrganisationId is null or ...)
* using :includeChildren to decide whether to match only root or descendants.
*/

View File

@ -0,0 +1,50 @@
/*
* VUActivity DRIVER_ACTIVITY extraction.
*
* Adapt table and column names to the concrete tachograph SQL Server schema.
* Keep the selected aliases stable; VuActivityRowMapper consumes this alias contract.
*/
select
cast(va.Id as varchar(128)) as source_row_id,
cast(va.Id as varchar(128)) as vu_activity_id,
concat('TACHOGRAPH:VU_ACTIVITY:', va.Id) as external_source_event_id,
va.ActivityTime as occurred_at,
va.ReceivedAt as received_partner_at,
va.Activity as activity_code,
va.ActivityText as activity_text,
va.EventType as event_type,
va.Lifecycle as lifecycle,
va.CardSlot as card_slot,
va.CardStatus as card_status,
va.DrivingStatus as driving_status,
va.OdometerM as odometer_m,
cast(va.DriverId as varchar(128)) as driver_source_entity_id,
va.DriverCardNation as driver_card_nation,
va.DriverCardNumber as driver_card_number,
cast(va.VehicleId as varchar(128)) as vehicle_source_entity_id,
va.VehicleVin as vehicle_vin,
va.VehicleRegistrationNation as vehicle_registration_nation,
va.VehicleRegistrationNumber as vehicle_registration_number,
'VEHICLE_UNIT' as source_package_kind,
cast(va.SourcePackageId as varchar(128)) as source_package_id,
cast(va.VehicleId as varchar(128)) as source_package_entity_id,
va.SourcePackagePeriodFrom as source_package_period_from,
va.SourcePackagePeriodTo as source_package_period_to,
va.SourcePackageImportedAt as source_package_imported_at
from VUActivity va
where (:occurredFrom is null or va.ActivityTime >= :occurredFrom)
and (:occurredTo is null or va.ActivityTime < :occurredTo)
and (
:lastSourcePackageImportedAt is null
or va.SourcePackageImportedAt > :lastSourcePackageImportedAt
or (va.SourcePackageImportedAt = :lastSourcePackageImportedAt and cast(va.SourcePackageId as varchar(128)) > :lastSourcePackageId)
)
/*
* Organisation filtering is schema-specific. Once stable joins are known, add:
* and (:rootOrganisationId is null or ...)
* using :includeChildren to decide whether to match only root or descendants.
*/