diff --git a/src/main/java/at/procon/eventhub/processing/support/RuntimeEventIdentityResolver.java b/src/main/java/at/procon/eventhub/processing/support/RuntimeEventIdentityResolver.java new file mode 100644 index 0000000..8e90f8e --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/support/RuntimeEventIdentityResolver.java @@ -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 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 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); + } +} diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageIntervalsModuleTest.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageIntervalsModuleTest.java new file mode 100644 index 0000000..c05742d --- /dev/null +++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageIntervalsModuleTest.java @@ -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 intervals = + (List) 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" + ); + } +} diff --git a/src/test/java/at/procon/eventhub/processing/service/UnifiedEventTimelineReconstructorTest.java b/src/test/java/at/procon/eventhub/processing/service/UnifiedEventTimelineReconstructorTest.java index 38a00ed..b110c4a 100644 --- a/src/test/java/at/procon/eventhub/processing/service/UnifiedEventTimelineReconstructorTest.java +++ b/src/test/java/at/procon/eventhub/processing/service/UnifiedEventTimelineReconstructorTest.java @@ -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 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 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 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,