Extend runtime event mixing rules and descriptors

This commit is contained in:
trifonovt 2026-06-11 14:19:39 +02:00
parent b4289abea5
commit 33a2f52d33
7 changed files with 563 additions and 0 deletions

View File

@ -0,0 +1,48 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
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 java.time.OffsetDateTime;
public record RuntimeEventDescriptor(
EventHubEventDto event,
String eventIdentityKey,
String eventKey,
RuntimeEventSourceProfile sourceProfile,
String compatibleActivityKey,
String compatibleSupportEvidenceKey,
boolean driverActivityPoint,
boolean driverCardUsagePoint,
boolean supportEvidenceCandidate
) {
public EventDomain eventDomain() {
return event == null ? null : event.eventDomain();
}
public EventType eventType() {
return event == null ? null : event.eventType();
}
public EventLifecycle lifecycle() {
return event == null ? null : event.lifecycle();
}
public OffsetDateTime occurredAt() {
return event == null ? null : event.occurredAt();
}
public String extractionCode() {
return sourceProfile == null ? null : sourceProfile.extractionCode();
}
public String keyFor(String equivalenceType) {
return switch (equivalenceType) {
case RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY -> eventKey;
case RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY -> compatibleActivityKey;
case RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_SUPPORT_KEY -> compatibleSupportEvidenceKey;
default -> eventKey;
};
}
}

View File

@ -0,0 +1,301 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.GeoPointDto;
import at.procon.eventhub.processing.support.RuntimeEntityReferenceResolver;
import at.procon.eventhub.processing.support.RuntimeEventIdentityResolver;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.springframework.stereotype.Component;
@Component
public class RuntimeEventDescriptorFactory {
public List<RuntimeEventDescriptor> describeSorted(List<EventHubEventDto> events) {
return sort(events).stream()
.map(this::describe)
.toList();
}
public RuntimeEventDescriptor describe(EventHubEventDto event) {
RuntimeEventSourceProfile profile = sourceProfile(event);
return new RuntimeEventDescriptor(
event,
eventIdentityKey(event),
RuntimeEventIdentityResolver.canonicalEventKey(event),
profile,
compatibleActivityKey(event),
compatibleSupportEvidenceKey(event),
isDriverActivityPoint(event),
isDriverCardUsagePoint(event),
isSupportEvidenceCandidate(event)
);
}
public List<EventHubEventDto> sort(List<EventHubEventDto> events) {
return (events == null ? List.<EventHubEventDto>of() : events).stream()
.filter(Objects::nonNull)
.sorted(eventComparator())
.toList();
}
public boolean isDriverActivityPoint(EventHubEventDto event) {
return event != null
&& event.eventDomain() == EventDomain.DRIVER_ACTIVITY
&& (event.lifecycle() == EventLifecycle.START || event.lifecycle() == EventLifecycle.END)
&& event.occurredAt() != null;
}
public boolean isDriverCardUsagePoint(EventHubEventDto event) {
return event != null
&& event.eventDomain() == EventDomain.DRIVER_CARD
&& (event.lifecycle() == EventLifecycle.INSERT || event.lifecycle() == EventLifecycle.WITHDRAW)
&& event.occurredAt() != null;
}
public boolean isSupportEvidenceCandidate(EventHubEventDto event) {
return event != null && !isDriverActivityPoint(event) && !isDriverCardUsagePoint(event);
}
public RuntimeEventSourceProfile sourceProfile(EventHubEventDto event) {
JsonNode raw = rawPayload(event);
String sourceKind = firstNonBlank(text(raw, "sourceKind"), sourceKind(event));
String extractionCode = firstNonBlank(
text(raw, "extractionCode"),
fileSessionExtractionCode(event, sourceKind),
extractionCodeFromExternalSourceEventId(event)
);
String sourceSystem = firstNonBlank(
text(raw, "sourceSystem"),
sourceProvider(event),
sourceSystemFromExternalSourceEventId(event)
);
if (sourceSystem == null && (extractionCode != null || isTachographFileSessionEvent(event))) {
sourceSystem = "TACHOGRAPH";
}
return new RuntimeEventSourceProfile(
normalizeUpper(sourceSystem),
normalizeUpper(sourceKind),
normalizeUpper(extractionCode)
);
}
public String eventIdentityKey(EventHubEventDto event) {
if (event == null) {
return "<null>";
}
return firstNonBlank(
event.externalSourceEventId(),
event.eventId() == null ? null : event.eventId().toString(),
RuntimeEventIdentityResolver.canonicalEventKey(event)
);
}
public Comparator<EventHubEventDto> eventComparator() {
return Comparator.comparing(EventHubEventDto::occurredAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(event -> event.eventDomain() == null ? "" : event.eventDomain().name())
.thenComparing(event -> event.eventType() == null ? "" : event.eventType().name())
.thenComparing(event -> event.lifecycle() == null ? "" : event.lifecycle().name())
.thenComparing(event -> sourceProfile(event).extractionCode(), Comparator.nullsLast(String::compareTo))
.thenComparing(EventHubEventDto::externalSourceEventId, Comparator.nullsLast(String::compareTo));
}
private String fileSessionExtractionCode(EventHubEventDto event, String sourceKind) {
if (!isTachographFileSessionEvent(event)) {
return null;
}
String normalizedSourceKind = normalizeUpper(sourceKind);
if (normalizedSourceKind == null) {
return null;
}
if (event != null && event.eventDomain() == EventDomain.DRIVER_ACTIVITY) {
return switch (normalizedSourceKind) {
case "DRIVER_CARD" -> "CARD_ACTIVITY";
case "VEHICLE_UNIT" -> "VU_ACTIVITY";
default -> null;
};
}
if (event != null && event.eventDomain() == EventDomain.DRIVER_CARD) {
return switch (normalizedSourceKind) {
case "DRIVER_CARD" -> "CARD_VEHICLES_USED";
case "VEHICLE_UNIT" -> "IW_CYCLE";
default -> null;
};
}
if (event == null || event.eventDomain() == null) {
return null;
}
String prefix = switch (normalizedSourceKind) {
case "DRIVER_CARD" -> "CARD";
case "VEHICLE_UNIT" -> "VU";
default -> null;
};
if (prefix == null) {
return null;
}
return switch (event.eventDomain()) {
case POSITION -> prefix + "_POSITION";
case PLACE -> prefix + "_PLACE";
case BORDER_CROSSING -> prefix + "_BORDER_CROSSING";
case SPEEDING -> Objects.equals("VU", prefix) ? "SPEEDING_EVENTS" : null;
default -> null;
};
}
private boolean isTachographFileSessionEvent(EventHubEventDto event) {
if (event == null) {
return false;
}
String packageKind = event.sourcePackageRef() == null ? null : normalizeUpper(event.sourcePackageRef().packageKind());
if (Objects.equals("TACHOGRAPH_FILE_SESSION", packageKind)
|| Objects.equals("COMPOSITE_TACHOGRAPH_FILE_SESSION", packageKind)) {
return true;
}
String provider = sourceProvider(event);
if (Objects.equals("TACHOGRAPH_FILE_SESSION", normalizeUpper(provider))
|| Objects.equals("COMPOSITE_TACHOGRAPH_FILE_SESSION", normalizeUpper(provider))) {
return true;
}
String sourceKey = event.packageInfo() == null || event.packageInfo().eventSource() == null
? null
: normalizeUpper(event.packageInfo().eventSource().sourceKey());
if (Objects.equals("TACHOGRAPH_FILE_SESSION", sourceKey)
|| Objects.equals("COMPOSITE_TACHOGRAPH_FILE_SESSION", sourceKey)) {
return true;
}
String externalId = event.externalSourceEventId();
return externalId != null
&& (externalId.startsWith("TACHOGRAPH_FILE_SESSION:")
|| externalId.startsWith("COMPOSITE_TACHOGRAPH_FILE_SESSION:"));
}
private String compatibleActivityKey(EventHubEventDto event) {
JsonNode raw = rawPayload(event);
return String.join("|",
"ACTIVITY_COMPATIBLE",
nullToEmpty(event == null || event.packageInfo() == null ? null : event.packageInfo().tenantKey()),
nullToEmpty(RuntimeEntityReferenceResolver.driverKey(event)),
nullToEmpty(event == null || event.eventDomain() == null ? null : event.eventDomain().name()),
nullToEmpty(event == null || event.eventType() == null ? null : event.eventType().name()),
nullToEmpty(event == null || event.lifecycle() == null ? null : event.lifecycle().name()),
normalizeTime(event == null ? null : event.occurredAt()),
nullToEmpty(RuntimeEntityReferenceResolver.registrationKey(event)),
nullToEmpty(firstNonBlank(text(raw, "startedAt"), text(raw, "intervalStartedAt"))),
nullToEmpty(firstNonBlank(text(raw, "endedAt"), text(raw, "intervalEndedAt"))),
nullToEmpty(firstNonBlank(text(raw, "slot"), text(raw, "cardSlot"))),
nullToEmpty(text(raw, "cardStatus")),
nullToEmpty(text(raw, "drivingStatus"))
);
}
private String compatibleSupportEvidenceKey(EventHubEventDto event) {
JsonNode raw = rawPayload(event);
GeoPointDto position = event == null ? null : event.position();
return String.join("|",
"SUPPORT_COMPATIBLE",
nullToEmpty(event == null || event.packageInfo() == null ? null : event.packageInfo().tenantKey()),
nullToEmpty(RuntimeEntityReferenceResolver.driverKey(event)),
nullToEmpty(event == null || event.eventDomain() == null ? null : event.eventDomain().name()),
nullToEmpty(event == null || event.eventType() == null ? null : event.eventType().name()),
nullToEmpty(event == null || event.lifecycle() == null ? null : event.lifecycle().name()),
normalizeTime(event == null ? null : event.occurredAt()),
nullToEmpty(RuntimeEntityReferenceResolver.registrationKey(event)),
nullToEmpty(firstNonBlank(text(raw, "latitude"), position == null || position.latitude() == null ? null : position.latitude().toPlainString())),
nullToEmpty(firstNonBlank(text(raw, "longitude"), position == null || position.longitude() == null ? null : position.longitude().toPlainString())),
nullToEmpty(firstNonBlank(text(raw, "odometerM"), event == null || event.odometerM() == null ? null : String.valueOf(event.odometerM()))),
nullToEmpty(firstNonBlank(text(raw, "country"), detailText(event, "country"))),
nullToEmpty(firstNonBlank(text(raw, "region"), detailText(event, "region"))),
nullToEmpty(firstNonBlank(text(raw, "countryFrom"), detailText(event, "countryFrom"))),
nullToEmpty(firstNonBlank(text(raw, "countryTo"), detailText(event, "countryTo"))),
nullToEmpty(firstNonBlank(text(raw, "operation"), detailText(event, "operation")))
);
}
private JsonNode rawPayload(EventHubEventDto event) {
return RuntimeEntityReferenceResolver.rawPayload(event);
}
private String sourceKind(EventHubEventDto event) {
return event == null || event.packageInfo() == null || event.packageInfo().eventSource() == null
? null
: event.packageInfo().eventSource().sourceKind();
}
private String sourceProvider(EventHubEventDto event) {
return event == null || event.packageInfo() == null || event.packageInfo().eventSource() == null
? null
: event.packageInfo().eventSource().providerKey();
}
private String sourceSystemFromExternalSourceEventId(EventHubEventDto event) {
String externalId = event == null ? null : event.externalSourceEventId();
if (externalId == null || externalId.isBlank()) {
return null;
}
String[] parts = externalId.split(":");
return parts.length >= 1 ? parts[0] : null;
}
private String extractionCodeFromExternalSourceEventId(EventHubEventDto event) {
String externalId = event == null ? null : event.externalSourceEventId();
if (externalId == null || externalId.isBlank()) {
return null;
}
String[] parts = externalId.split(":");
return parts.length >= 2 ? parts[1] : null;
}
private String detailText(EventHubEventDto event, String field) {
if (event == null || event.eventDetails() == null || event.eventDetails().attributes() == null || field == null) {
return null;
}
JsonNode value = event.eventDetails().attributes().get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText(null);
return text == null || text.isBlank() ? null : text.trim();
}
private String text(JsonNode node, String field) {
if (node == null || field == null) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText(null);
return text == null || text.isBlank() ? null : text.trim();
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value.trim();
}
}
return null;
}
private String normalizeUpper(String value) {
return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT);
}
private String nullToEmpty(Object value) {
return value == null ? "" : String.valueOf(value);
}
private String normalizeTime(OffsetDateTime value) {
return value == null ? "" : value.toInstant().toString();
}
}

View File

@ -0,0 +1,9 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
public enum RuntimeEventMixingChannel {
ACTIVITY_TIMELINE,
VEHICLE_USAGE,
SUPPORT_EVIDENCE,
VALIDATION,
AUDIT
}

View File

@ -0,0 +1,77 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
import java.util.Set;
public record RuntimeEventMixingRule(
String ruleId,
RuntimeEventMixingChannel channel,
String equivalenceType,
Set<EventDomain> eventDomains,
Set<EventType> eventTypes,
Set<EventLifecycle> lifecycles,
Set<String> primaryExtractionCodes,
Set<String> secondaryExtractionCodes,
RuntimeResolvedEventRole primaryRole,
RuntimeResolvedEventRole secondaryRole,
String decision,
String reason
) {
public static final String EQUIVALENCE_EXACT_EVENT_KEY = "EXACT_EVENT_KEY";
public static final String EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY = "COMPATIBLE_ACTIVITY_KEY";
public static final String EQUIVALENCE_COMPATIBLE_SUPPORT_KEY = "COMPATIBLE_SUPPORT_KEY";
public RuntimeEventMixingRule {
eventDomains = eventDomains == null ? Set.of() : Set.copyOf(eventDomains);
eventTypes = eventTypes == null ? Set.of() : Set.copyOf(eventTypes);
lifecycles = lifecycles == null ? Set.of() : Set.copyOf(lifecycles);
primaryExtractionCodes = normalize(primaryExtractionCodes);
secondaryExtractionCodes = normalize(secondaryExtractionCodes);
}
public boolean matches(RuntimeEventDescriptor descriptor) {
if (descriptor == null || descriptor.event() == null || descriptor.sourceProfile() == null) {
return false;
}
if (!descriptor.sourceProfile().isTachographRuntimeSource()) {
return false;
}
if (!eventDomains.isEmpty() && !eventDomains.contains(descriptor.eventDomain())) {
return false;
}
if (!eventTypes.isEmpty() && !eventTypes.contains(descriptor.eventType())) {
return false;
}
if (!lifecycles.isEmpty() && !lifecycles.contains(descriptor.lifecycle())) {
return false;
}
if (channel == RuntimeEventMixingChannel.ACTIVITY_TIMELINE && !descriptor.driverActivityPoint()) {
return false;
}
if (channel == RuntimeEventMixingChannel.SUPPORT_EVIDENCE && !descriptor.supportEvidenceCandidate()) {
return false;
}
String extractionCode = descriptor.extractionCode();
return primaryExtractionCodes.contains(extractionCode) || secondaryExtractionCodes.contains(extractionCode);
}
public boolean isPrimary(RuntimeEventDescriptor descriptor) {
return descriptor != null && primaryExtractionCodes.contains(descriptor.extractionCode());
}
public boolean isSecondary(RuntimeEventDescriptor descriptor) {
return descriptor != null && secondaryExtractionCodes.contains(descriptor.extractionCode());
}
private static Set<String> normalize(Set<String> values) {
if (values == null || values.isEmpty()) {
return Set.of();
}
return values.stream()
.filter(value -> value != null && !value.isBlank())
.map(value -> value.trim().toUpperCase(java.util.Locale.ROOT))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
}

View File

@ -0,0 +1,94 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Component;
@Component
public class RuntimeEventMixingRuleRegistry {
public List<RuntimeEventMixingRule> rulesForMode(String mode) {
if (RuntimeEventMixingService.MODE_OFF.equals(mode)) {
return List.of();
}
return List.of(
tachographCardVuActivityExactEventKey(),
tachographCardVuSupportExactEventKey(),
tachographCardVuActivityCompatibleKey(),
tachographCardVuSupportCompatibleKey()
);
}
private RuntimeEventMixingRule tachographCardVuActivityExactEventKey() {
return new RuntimeEventMixingRule(
RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_ACTIVITY_SAME_EVENT_KEY,
RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY,
Set.of(EventDomain.DRIVER_ACTIVITY),
Set.of(EventType.DRIVE, EventType.BREAK_REST, EventType.AVAILABILITY, EventType.WORK, EventType.UNKNOWN_ACTIVITY),
Set.of(EventLifecycle.START, EventLifecycle.END),
Set.of("CARD_ACTIVITY"),
Set.of("VU_ACTIVITY"),
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"FUSED_PRIMARY_SELECTED",
"CARD_ACTIVITY and VU_ACTIVITY describe the same driver activity point. CARD_ACTIVITY is kept as primary for the activity timeline; VU_ACTIVITY is suppressed from activity intervalization."
);
}
private RuntimeEventMixingRule tachographCardVuActivityCompatibleKey() {
return new RuntimeEventMixingRule(
RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_ACTIVITY_COMPATIBLE_KEY,
RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY,
Set.of(EventDomain.DRIVER_ACTIVITY),
Set.of(EventType.DRIVE, EventType.BREAK_REST, EventType.AVAILABILITY, EventType.WORK, EventType.UNKNOWN_ACTIVITY),
Set.of(EventLifecycle.START, EventLifecycle.END),
Set.of("CARD_ACTIVITY"),
Set.of("VU_ACTIVITY"),
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"FUSED_PRIMARY_SELECTED",
"CARD_ACTIVITY and VU_ACTIVITY describe a compatible driver activity point. CARD_ACTIVITY is kept as primary for the activity timeline; VU_ACTIVITY is suppressed from activity intervalization."
);
}
private RuntimeEventMixingRule tachographCardVuSupportExactEventKey() {
return new RuntimeEventMixingRule(
RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_SUPPORT_SAME_EVENT_KEY,
RuntimeEventMixingChannel.SUPPORT_EVIDENCE,
RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY,
Set.of(EventDomain.POSITION, EventDomain.PLACE, EventDomain.BORDER_CROSSING),
Set.of(EventType.POSITION_RECORDED, EventType.WORKING_DAY_PLACE_RECORDED,
EventType.BORDER_INBOUND, EventType.BORDER_OUTBOUND, EventType.BORDER_OUT_EU),
Set.of(EventLifecycle.SNAPSHOT, EventLifecycle.INBOUND, EventLifecycle.OUTBOUND, EventLifecycle.OUT_EU),
Set.of("CARD_POSITION", "CARD_PLACE", "CARD_BORDER_CROSSING"),
Set.of("VU_POSITION", "VU_PLACE", "VU_BORDER_CROSSING"),
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"FUSED_PRIMARY_SELECTED",
"CARD and VU support evidence describe the same semantic event. CARD evidence is kept as primary support evidence; VU evidence is suppressed from support-evidence normalization but retained as audit/corroborating evidence."
);
}
private RuntimeEventMixingRule tachographCardVuSupportCompatibleKey() {
return new RuntimeEventMixingRule(
RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_SUPPORT_COMPATIBLE_KEY,
RuntimeEventMixingChannel.SUPPORT_EVIDENCE,
RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_SUPPORT_KEY,
Set.of(EventDomain.POSITION, EventDomain.PLACE, EventDomain.BORDER_CROSSING),
Set.of(EventType.POSITION_RECORDED, EventType.WORKING_DAY_PLACE_RECORDED,
EventType.BORDER_INBOUND, EventType.BORDER_OUTBOUND, EventType.BORDER_OUT_EU),
Set.of(EventLifecycle.SNAPSHOT, EventLifecycle.INBOUND, EventLifecycle.OUTBOUND, EventLifecycle.OUT_EU),
Set.of("CARD_POSITION", "CARD_PLACE", "CARD_BORDER_CROSSING"),
Set.of("VU_POSITION", "VU_PLACE", "VU_BORDER_CROSSING"),
RuntimeResolvedEventRole.FUSED_PRIMARY,
RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
"FUSED_PRIMARY_SELECTED",
"CARD and VU support evidence describe a compatible semantic event. CARD evidence is kept as primary support evidence; VU evidence is suppressed from support-evidence normalization. Vehicle/VIN identity from the VU event is copied to the primary event when the card event has weaker vehicle identity."
);
}
}

View File

@ -0,0 +1,22 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
import at.procon.eventhub.dto.EventHubEventDto;
import java.util.List;
public record RuntimeResolvedEvent(
EventHubEventDto event,
RuntimeEventMixingChannel channel,
RuntimeResolvedEventRole role,
String ruleId,
String equivalenceType,
String effectiveEventKey,
String primaryExternalSourceEventId,
List<String> relatedExternalSourceEventIds,
String reason
) {
public RuntimeResolvedEvent {
relatedExternalSourceEventIds = relatedExternalSourceEventIds == null
? List.of()
: List.copyOf(relatedExternalSourceEventIds);
}
}

View File

@ -0,0 +1,12 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
public enum RuntimeResolvedEventRole {
PRIMARY,
FUSED_PRIMARY,
SECONDARY_CORROBORATING,
FALLBACK_PRIMARY,
SUPPRESSED_DUPLICATE,
SUPPORT_ONLY,
CONFLICTING_EVIDENCE,
VEHICLE_USAGE_INPUT
}