Fix driver card usage interval pairing

This commit is contained in:
trifonovt 2026-06-05 12:02:48 +02:00
parent 6dd1fb7447
commit 7457872d51
3 changed files with 488 additions and 6 deletions

View File

@ -0,0 +1,226 @@
package at.procon.eventhub.processing.support;
import at.procon.eventhub.dto.EventDetailsDto;
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 com.fasterxml.jackson.databind.JsonNode;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
public final class RuntimeEventIdentityResolver {
private RuntimeEventIdentityResolver() {
}
public static String canonicalEventKey(EventHubEventDto event) {
if (event == null) {
return "NULL_EVENT";
}
JsonNode raw = rawPayload(event);
JsonNode details = detailAttributes(event);
List<String> parts = new ArrayList<>();
parts.add("EVENT");
parts.add(nullToEmpty(event.packageInfo() == null ? null : event.packageInfo().tenantKey()));
parts.add(nullToEmpty(event.eventDomain() == null ? null : event.eventDomain().name()));
parts.add(nullToEmpty(event.eventType() == null ? null : event.eventType().name()));
parts.add(nullToEmpty(event.lifecycle() == null ? null : event.lifecycle().name()));
parts.add(normalizeTime(event.occurredAt()));
parts.add(nullToEmpty(TachographRuntimeIdentityResolver.driverKey(event)));
parts.add(nullToEmpty(TachographRuntimeIdentityResolver.registrationKey(event)));
parts.add(nullToEmpty(TachographRuntimeIdentityResolver.vehicleKey(event)));
parts.add(isIntervalPointEvent(event) ? nullToEmpty(runtimeIntervalKey(event)) : "");
parts.add(nullToEmpty(firstNonBlank(text(raw, "activityType"), event.eventType() == null ? null : event.eventType().name())));
parts.add(nullToEmpty(firstNonBlank(text(raw, "slot"), text(raw, "cardSlot"), text(details, "cardSlot"))));
parts.add(nullToEmpty(firstNonBlank(text(raw, "cardStatus"), text(details, "cardStatus"))));
parts.add(nullToEmpty(firstNonBlank(text(raw, "drivingStatus"), text(details, "drivingStatus"))));
parts.add(nullToEmpty(firstNonBlank(text(raw, "supportEventType"), event.eventType() == null ? null : event.eventType().name())));
parts.add(nullToEmpty(firstNonBlank(text(raw, "country"), text(details, "country"))));
parts.add(nullToEmpty(text(raw, "region")));
parts.add(nullToEmpty(firstNonBlank(text(raw, "countryFrom"), text(details, "countryFrom"))));
parts.add(nullToEmpty(firstNonBlank(text(raw, "countryTo"), text(details, "countryTo"))));
parts.add(nullToEmpty(firstNonBlank(text(raw, "operation"), text(details, "operation"))));
parts.add(nullToEmpty(text(raw, "authenticationStatus")));
parts.add(nullToEmpty(text(raw, "code")));
parts.add(nullToEmpty(firstNonBlank(
numberText(firstNonBlankNode(raw, "odometerKm")),
numberText(firstNonBlankNode(raw, "odometerM"))
)));
parts.add(nullToEmpty(positionKey(event)));
return String.join("|", parts);
}
public static String runtimeIntervalKey(EventHubEventDto event) {
if (event == null) {
return null;
}
String originalIntervalId = presentationIntervalId(event);
if (!isIntervalPointEvent(event)) {
return originalIntervalId;
}
JsonNode raw = rawPayload(event);
OffsetDateTime startedAt = intervalStartedAt(raw);
OffsetDateTime endedAt = intervalEndedAt(raw);
if (startedAt == null && endedAt == null) {
return originalIntervalId;
}
JsonNode details = detailAttributes(event);
List<String> parts = new ArrayList<>();
parts.add("INTERVAL");
parts.add(nullToEmpty(event.eventDomain() == null ? null : event.eventDomain().name()));
parts.add(nullToEmpty(intervalSemanticType(event, raw)));
parts.add(nullToEmpty(TachographRuntimeIdentityResolver.driverKey(event)));
parts.add(nullToEmpty(TachographRuntimeIdentityResolver.registrationKey(event)));
parts.add(nullToEmpty(TachographRuntimeIdentityResolver.vehicleKey(event)));
parts.add(nullToEmpty(intervalCardSlot(event, raw, details)));
parts.add(nullToEmpty(intervalCardStatus(event, raw, details)));
parts.add(nullToEmpty(intervalDrivingStatus(event, raw, details)));
parts.add(normalizeTime(startedAt));
parts.add(normalizeTime(endedAt));
return String.join("|", parts);
}
public static String presentationIntervalId(EventHubEventDto event) {
JsonNode raw = rawPayload(event);
return firstNonBlank(
text(raw, "intervalId"),
text(raw, "sourceRowId"),
event == null ? null : event.externalSourceEventId()
);
}
private static boolean isIntervalPointEvent(EventHubEventDto event) {
if (event == null || event.eventDomain() == null || event.lifecycle() == null) {
return false;
}
if (event.eventDomain() == EventDomain.DRIVER_ACTIVITY) {
return event.lifecycle() == EventLifecycle.START || event.lifecycle() == EventLifecycle.END;
}
if (event.eventDomain() != EventDomain.DRIVER_CARD) {
return false;
}
return event.eventType() == EventType.CARD_INSERTED || event.eventType() == EventType.CARD_WITHDRAWN;
}
private static OffsetDateTime intervalStartedAt(JsonNode raw) {
return time(firstNonBlank(text(raw, "startedAt"), text(raw, "intervalStartedAt")));
}
private static OffsetDateTime intervalEndedAt(JsonNode raw) {
return time(firstNonBlank(text(raw, "endedAt"), text(raw, "intervalEndedAt")));
}
private static String intervalSemanticType(EventHubEventDto event, JsonNode raw) {
if (event == null) {
return null;
}
if (event.eventDomain() == EventDomain.DRIVER_ACTIVITY) {
return firstNonBlank(text(raw, "activityType"), event.eventType() == null ? null : event.eventType().name());
}
if (event.eventDomain() == EventDomain.DRIVER_CARD) {
return "CARD_USAGE";
}
return event.eventType() == null ? null : event.eventType().name();
}
private static String intervalCardSlot(EventHubEventDto event, JsonNode raw, JsonNode details) {
return firstNonBlank(text(raw, "slot"), text(raw, "cardSlot"), text(details, "cardSlot"));
}
private static String intervalCardStatus(EventHubEventDto event, JsonNode raw, JsonNode details) {
if (event != null && event.eventDomain() == EventDomain.DRIVER_CARD) {
return null;
}
return firstNonBlank(text(raw, "cardStatus"), text(details, "cardStatus"));
}
private static String intervalDrivingStatus(EventHubEventDto event, JsonNode raw, JsonNode details) {
if (event != null && event.eventDomain() == EventDomain.DRIVER_CARD) {
return null;
}
return firstNonBlank(text(raw, "drivingStatus"), text(details, "drivingStatus"));
}
private static String positionKey(EventHubEventDto event) {
if (event == null || event.position() == null) {
return null;
}
return nullToEmpty(event.position().latitude()) + ":" + nullToEmpty(event.position().longitude());
}
private static JsonNode rawPayload(EventHubEventDto event) {
return TachographRuntimeIdentityResolver.rawPayload(event);
}
private static JsonNode detailAttributes(EventHubEventDto event) {
EventDetailsDto details = event == null ? null : event.eventDetails();
return details == null ? null : details.attributes();
}
private static JsonNode firstNonBlankNode(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.isTextual() && value.asText().isBlank()) {
return null;
}
return value;
}
private static String numberText(JsonNode value) {
if (value == null || value.isNull()) {
return null;
}
return value.isNumber() ? value.numberValue().toString() : text(value, null);
}
private static OffsetDateTime time(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return OffsetDateTime.parse(value.trim());
} catch (DateTimeParseException ignored) {
return null;
}
}
private static String normalizeTime(OffsetDateTime value) {
return value == null ? "" : value.toInstant().toString();
}
private static String text(JsonNode node, String field) {
if (node == null) {
return null;
}
JsonNode value = field == null ? node : node.get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText(null);
return text == null || text.isBlank() ? null : text.trim();
}
private static String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value.trim();
}
}
return null;
}
private static String nullToEmpty(Object value) {
return value == null ? "" : String.valueOf(value);
}
}

View File

@ -0,0 +1,170 @@
package at.procon.eventhub.processing.eventprocessing.module;
import static org.assertj.core.api.Assertions.assertThat;
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.ImportScopeDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingExecutionApiRequest;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class DriverVehicleUsageIntervalsModuleTest {
@Test
void pairsVehicleUsagePointsWhenCardStatusDiffersAcrossLifecycleEvents() {
DriverVehicleUsageIntervalsModule module = new DriverVehicleUsageIntervalsModule();
RuntimeProcessingModuleContext context = new RuntimeProcessingModuleContext(
new RuntimeProcessingExecutionApiRequest(
"driver-working-time-v1",
runtimeScope(),
null,
List.of(),
Map.of()
),
List.of(
vehicleUsageEvent(
"CVU-1",
EventType.CARD_INSERTED,
EventLifecycle.INSERT,
"2026-05-01T07:50:00Z",
"2026-05-01T07:50:00Z",
"2026-05-01T10:10:00Z",
100_000L,
"INSERTED"
),
vehicleUsageEvent(
"CVU-99",
EventType.CARD_WITHDRAWN,
EventLifecycle.WITHDRAW,
"2026-05-01T10:10:00Z",
"2026-05-01T07:50:00Z",
"2026-05-01T10:10:00Z",
140_000L,
"NOT_INSERTED"
)
),
Map.of(),
Map.of()
);
RuntimeProcessingModuleResult result = module.execute(context);
assertThat(result.status()).isEqualTo(RuntimeProcessingModuleStatus.SUCCESS);
@SuppressWarnings("unchecked")
List<DriverWorkingTimeVehicleUsageInterval> intervals =
(List<DriverWorkingTimeVehicleUsageInterval>) result.output();
assertThat(intervals).hasSize(1);
assertThat(intervals.get(0).intervalId()).isEqualTo("CVU-1");
assertThat(intervals.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T07:50:00Z"));
assertThat(intervals.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:10:00Z"));
}
private UnifiedRuntimeProcessingApiRequest runtimeScope() {
return new UnifiedRuntimeProcessingApiRequest(
UUID.randomUUID(),
List.of(),
null,
"default",
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
null,
null,
"DRIVER:42",
Set.of(),
false,
Set.of(),
false,
null,
null,
null,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
true,
0,
null,
null,
null,
false,
false
);
}
private EventHubEventDto vehicleUsageEvent(
String intervalId,
EventType eventType,
EventLifecycle lifecycle,
String occurredAt,
String startedAt,
String endedAt,
long odometerM,
String cardStatus
) {
ObjectNode raw = JsonNodeFactory.instance.objectNode();
raw.put("intervalId", intervalId);
raw.put("driverKey", "DRIVER:42");
raw.put("startedAt", startedAt);
raw.put("endedAt", endedAt);
raw.put("registrationKey", "12:REG-1");
raw.put("vehicleKey", "VIN-1");
raw.put("sourceKind", "DRIVER_CARD");
raw.putArray("sourceRowIds").add("row-" + intervalId);
ObjectNode payload = JsonNodeFactory.instance.objectNode();
payload.set("raw", raw);
ObjectNode attributes = JsonNodeFactory.instance.objectNode();
attributes.put("cardStatus", cardStatus);
OffsetDateTime occurred = OffsetDateTime.parse(occurredAt);
return new EventHubEventDto(
UUID.randomUUID(),
intervalId + ":" + eventType.name(),
new DriverRefDto("DRIVER:42", null),
new VehicleRefDto("VEH-1", "VIN-1", "VR-1", new VehicleRegistrationRefDto("12", 12, "REG-1")),
occurred,
null,
occurred,
EventDomain.DRIVER_CARD,
eventType,
lifecycle,
odometerM,
null,
new EventDetailsDto("DRIVER_CARD", attributes),
null,
payload,
false,
packageInfo()
);
}
private EventHubPackageRequest packageInfo() {
EventSourceDto source = new EventSourceDto("TACHOGRAPH", "DRIVER_CARD", "SOURCE", null, null, null);
return new EventHubPackageRequest(
"default",
source,
null,
ImportScopeDto.tenantAll(
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-02T00:00:00Z")
),
EventDomain.DRIVER_CARD.name(),
LocalDate.parse("2026-05-01"),
source.stableKey() + ":" + EventDomain.DRIVER_CARD.name() + ":2026-05-01"
);
}
}

View File

@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventDetailsDto;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventSourceDto;
@ -30,10 +31,10 @@ class UnifiedEventTimelineReconstructorTest {
@Test
void reconstructsTimelineFromUnifiedEvents() {
List<EventHubEventDto> events = List.of(
activityEvent("ACT-1", EventLifecycle.START, "2026-05-01T08:00:00Z"),
activityEvent("ACT-1", EventLifecycle.END, "2026-05-01T09:00:00Z"),
vehicleUsageEvent("CVU-1", EventType.CARD_INSERTED, EventLifecycle.INSERT, "2026-05-01T07:50:00Z", 100_000L),
vehicleUsageEvent("CVU-1", EventType.CARD_WITHDRAWN, EventLifecycle.WITHDRAW, "2026-05-01T10:10:00Z", 140_000L),
activityEvent("ACT-1", EventLifecycle.START, "2026-05-01T08:00:00Z", "2026-05-01T08:00:00Z", "2026-05-01T09:00:00Z"),
activityEvent("ACT-1", EventLifecycle.END, "2026-05-01T09:00:00Z", "2026-05-01T08:00:00Z", "2026-05-01T09:00:00Z"),
vehicleUsageEvent("CVU-1", EventType.CARD_INSERTED, EventLifecycle.INSERT, "2026-05-01T07:50:00Z", "2026-05-01T07:50:00Z", "2026-05-01T10:10:00Z", 100_000L),
vehicleUsageEvent("CVU-1", EventType.CARD_WITHDRAWN, EventLifecycle.WITHDRAW, "2026-05-01T10:10:00Z", "2026-05-01T07:50:00Z", "2026-05-01T10:10:00Z", 140_000L),
supportEvent("SUP-1", "2026-05-01T08:30:00Z")
);
@ -60,9 +61,73 @@ class UnifiedEventTimelineReconstructorTest {
assertThat(timeline.supportEvents().get(0).latitude()).isEqualByComparingTo(BigDecimal.valueOf(48.2082));
}
private EventHubEventDto activityEvent(String intervalId, EventLifecycle lifecycle, String occurredAt) {
@Test
void reconstructsCrossSourceActivityIntervalUsingRuntimeIntervalKey() {
List<EventHubEventDto> events = List.of(
activityEvent("CARD-ACT-1", EventLifecycle.START, "2026-05-01T08:00:00Z", "2026-05-01T08:00:00Z", "2026-05-01T09:00:00Z"),
activityEvent("VU-ACT-99", EventLifecycle.END, "2026-05-01T09:00:00Z", "2026-05-01T08:00:00Z", "2026-05-01T09:00:00Z")
);
ResolvedDriverTimeline timeline = reconstructor.reconstruct(
UUID.randomUUID(),
"DRIVER:42",
events
);
assertThat(timeline.activityIntervals()).hasSize(1);
assertThat(timeline.activityIntervals().get(0).intervalId()).isEqualTo("CARD-ACT-1");
assertThat(timeline.activityIntervals().get(0).from()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:00:00Z"));
assertThat(timeline.activityIntervals().get(0).to()).isEqualTo(OffsetDateTime.parse("2026-05-01T09:00:00Z"));
}
@Test
void reconstructsVehicleUsageIntervalWhenCardStatusDiffersAcrossLifecycleEvents() {
List<EventHubEventDto> events = List.of(
vehicleUsageEvent(
"CVU-1",
EventType.CARD_INSERTED,
EventLifecycle.INSERT,
"2026-05-01T07:50:00Z",
"2026-05-01T07:50:00Z",
"2026-05-01T10:10:00Z",
100_000L,
"INSERTED"
),
vehicleUsageEvent(
"CVU-99",
EventType.CARD_WITHDRAWN,
EventLifecycle.WITHDRAW,
"2026-05-01T10:10:00Z",
"2026-05-01T07:50:00Z",
"2026-05-01T10:10:00Z",
140_000L,
"NOT_INSERTED"
)
);
ResolvedDriverTimeline timeline = reconstructor.reconstruct(
UUID.randomUUID(),
"DRIVER:42",
events
);
assertThat(timeline.vehicleUsageIntervals()).hasSize(1);
assertThat(timeline.vehicleUsageIntervals().get(0).intervalId()).isEqualTo("CVU-1");
assertThat(timeline.vehicleUsageIntervals().get(0).from()).isEqualTo(OffsetDateTime.parse("2026-05-01T07:50:00Z"));
assertThat(timeline.vehicleUsageIntervals().get(0).to()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:10:00Z"));
}
private EventHubEventDto activityEvent(
String intervalId,
EventLifecycle lifecycle,
String occurredAt,
String startedAt,
String endedAt
) {
ObjectNode raw = JsonNodeFactory.instance.objectNode();
raw.put("intervalId", intervalId);
raw.put("startedAt", startedAt);
raw.put("endedAt", endedAt);
raw.put("registrationKey", "12:REG-1");
raw.put("vehicleKey", "VIN-1");
raw.put("sourceKind", "DRIVER_CARD");
@ -95,16 +160,37 @@ class UnifiedEventTimelineReconstructorTest {
EventType eventType,
EventLifecycle lifecycle,
String occurredAt,
String startedAt,
String endedAt,
long odometerM
) {
return vehicleUsageEvent(intervalId, eventType, lifecycle, occurredAt, startedAt, endedAt, odometerM, null);
}
private EventHubEventDto vehicleUsageEvent(
String intervalId,
EventType eventType,
EventLifecycle lifecycle,
String occurredAt,
String startedAt,
String endedAt,
long odometerM,
String cardStatus
) {
ObjectNode raw = JsonNodeFactory.instance.objectNode();
raw.put("intervalId", intervalId);
raw.put("startedAt", startedAt);
raw.put("endedAt", endedAt);
raw.put("registrationKey", "12:REG-1");
raw.put("vehicleKey", "VIN-1");
raw.put("sourceKind", "DRIVER_CARD");
raw.putArray("sourceRowIds").add("row-" + intervalId);
ObjectNode payload = JsonNodeFactory.instance.objectNode();
payload.set("raw", raw);
ObjectNode attributes = JsonNodeFactory.instance.objectNode();
if (cardStatus != null) {
attributes.put("cardStatus", cardStatus);
}
return new EventHubEventDto(
UUID.randomUUID(),
intervalId + ":" + eventType.name(),
@ -118,7 +204,7 @@ class UnifiedEventTimelineReconstructorTest {
lifecycle,
odometerM,
null,
null,
cardStatus == null ? null : new EventDetailsDto("DRIVER_CARD", attributes),
null,
payload,
false,