Add configurable Esper activity pipeline and driver identity model

This commit is contained in:
trifonovt 2026-05-06 09:02:38 +02:00
parent 094007d817
commit 818009555a
25 changed files with 2197 additions and 179 deletions

View File

@ -1,5 +1,7 @@
package at.procon.eventhub.config; package at.procon.eventhub.config;
import at.procon.eventhub.esperpoc.dto.EsperActivityMergeMode;
import at.procon.eventhub.esperpoc.dto.EsperShiftResolutionMode;
import java.time.Duration; import java.time.Duration;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList; import java.util.ArrayList;
@ -26,6 +28,7 @@ public class EventHubProperties {
private final Batch batch = new Batch(); private final Batch batch = new Batch();
private final Tachograph tachograph = new Tachograph(); private final Tachograph tachograph = new Tachograph();
private final EsperPoc esperPoc = new EsperPoc();
private final YellowFox yellowFox = new YellowFox(); private final YellowFox yellowFox = new YellowFox();
public Batch getBatch() { public Batch getBatch() {
@ -36,10 +39,35 @@ public class EventHubProperties {
return tachograph; return tachograph;
} }
public EsperPoc getEsperPoc() {
return esperPoc;
}
public YellowFox getYellowFox() { public YellowFox getYellowFox() {
return yellowFox; return yellowFox;
} }
public static class EsperPoc {
private EsperActivityMergeMode activityMergeMode = EsperActivityMergeMode.JAVA;
private EsperShiftResolutionMode shiftResolutionMode = EsperShiftResolutionMode.JAVA;
public EsperActivityMergeMode getActivityMergeMode() {
return activityMergeMode;
}
public void setActivityMergeMode(EsperActivityMergeMode activityMergeMode) {
this.activityMergeMode = activityMergeMode == null ? EsperActivityMergeMode.JAVA : activityMergeMode;
}
public EsperShiftResolutionMode getShiftResolutionMode() {
return shiftResolutionMode;
}
public void setShiftResolutionMode(EsperShiftResolutionMode shiftResolutionMode) {
this.shiftResolutionMode = shiftResolutionMode == null ? EsperShiftResolutionMode.JAVA : shiftResolutionMode;
}
}
public static class Batch { public static class Batch {
/** Number of events collected before a package is persisted. */ /** Number of events collected before a package is persisted. */
private int completionSize = 5000; private int completionSize = 5000;

View File

@ -1,7 +1,9 @@
package at.procon.eventhub.esperpoc.api; package at.procon.eventhub.esperpoc.api;
import at.procon.eventhub.esperpoc.dto.EsperActivityMergeMode;
import at.procon.eventhub.esperpoc.dto.EsperPocRequest; import at.procon.eventhub.esperpoc.dto.EsperPocRequest;
import at.procon.eventhub.esperpoc.dto.EsperPocResultDto; import at.procon.eventhub.esperpoc.dto.EsperPocResultDto;
import at.procon.eventhub.esperpoc.dto.EsperShiftResolutionMode;
import at.procon.eventhub.esperpoc.service.EsperPocDriverCardActivityService; import at.procon.eventhub.esperpoc.service.EsperPocDriverCardActivityService;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
@ -31,7 +33,11 @@ public class EsperPocController {
@RequestParam(defaultValue = "24") Integer guardHours, @RequestParam(defaultValue = "24") Integer guardHours,
@RequestParam(defaultValue = "3") Integer significantDrivingMinutes, @RequestParam(defaultValue = "3") Integer significantDrivingMinutes,
@RequestParam(defaultValue = "60") Integer mergeGapSeconds, @RequestParam(defaultValue = "60") Integer mergeGapSeconds,
@RequestParam(defaultValue = "7") Integer operatingPeriodSplitRestHours @RequestParam(defaultValue = "7") Integer operatingPeriodSplitRestHours,
@RequestParam(defaultValue = "420") Integer shiftEndMarkPeriodMinutes,
@RequestParam(defaultValue = "5") Integer absenceBeginEndMinActivityMinutes,
@RequestParam(required = false) EsperActivityMergeMode activityMergeMode,
@RequestParam(required = false) EsperShiftResolutionMode shiftResolutionMode
) { ) {
EsperPocRequest request = new EsperPocRequest( EsperPocRequest request = new EsperPocRequest(
tenantKey, tenantKey,
@ -41,7 +47,11 @@ public class EsperPocController {
guardHours, guardHours,
significantDrivingMinutes, significantDrivingMinutes,
mergeGapSeconds, mergeGapSeconds,
operatingPeriodSplitRestHours operatingPeriodSplitRestHours,
shiftEndMarkPeriodMinutes,
absenceBeginEndMinActivityMinutes,
activityMergeMode,
shiftResolutionMode
); );
return ResponseEntity.ok(service.evaluate(request)); return ResponseEntity.ok(service.evaluate(request));
} }

View File

@ -11,6 +11,9 @@ public record ActivityIntervalDto(
UUID vehicleRegistrationId, UUID vehicleRegistrationId,
String activityType, String activityType,
String cardSlot, String cardSlot,
String cardStatus,
String drivingStatus,
String sourceKind,
OffsetDateTime startedAt, OffsetDateTime startedAt,
OffsetDateTime endedAt, OffsetDateTime endedAt,
long durationSeconds, long durationSeconds,
@ -25,6 +28,9 @@ public record ActivityIntervalDto(
UUID vehicleRegistrationId, UUID vehicleRegistrationId,
String activityType, String activityType,
String cardSlot, String cardSlot,
String cardStatus,
String drivingStatus,
String sourceKind,
OffsetDateTime startedAt, OffsetDateTime startedAt,
OffsetDateTime endedAt, OffsetDateTime endedAt,
String sourceRowId String sourceRowId
@ -35,6 +41,9 @@ public record ActivityIntervalDto(
vehicleRegistrationId, vehicleRegistrationId,
activityType, activityType,
cardSlot, cardSlot,
cardStatus,
drivingStatus,
sourceKind,
startedAt, startedAt,
endedAt, endedAt,
Duration.between(startedAt, endedAt).getSeconds(), Duration.between(startedAt, endedAt).getSeconds(),
@ -52,6 +61,9 @@ public record ActivityIntervalDto(
vehicleRegistrationId, vehicleRegistrationId,
activityType, activityType,
cardSlot, cardSlot,
cardStatus,
drivingStatus,
sourceKind,
newStartedAt, newStartedAt,
newEndedAt, newEndedAt,
Duration.between(newStartedAt, newEndedAt).getSeconds(), Duration.between(newStartedAt, newEndedAt).getSeconds(),
@ -69,6 +81,9 @@ public record ActivityIntervalDto(
vehicleRegistrationId, vehicleRegistrationId,
activityType, activityType,
cardSlot, cardSlot,
cardStatus,
drivingStatus,
sourceKind,
startedAt, startedAt,
endedAt, endedAt,
durationSeconds, durationSeconds,

View File

@ -0,0 +1,6 @@
package at.procon.eventhub.esperpoc.dto;
public enum EsperActivityMergeMode {
JAVA,
ESPER
}

View File

@ -13,7 +13,11 @@ public record EsperPocRequest(
Integer guardHours, Integer guardHours,
Integer significantDrivingMinutes, Integer significantDrivingMinutes,
Integer mergeGapSeconds, Integer mergeGapSeconds,
Integer operatingPeriodSplitRestHours Integer operatingPeriodSplitRestHours,
Integer shiftEndMarkPeriodMinutes,
Integer absenceBeginEndMinActivityMinutes,
EsperActivityMergeMode activityMergeMode,
EsperShiftResolutionMode shiftResolutionMode
) { ) {
public EsperPocRequest { public EsperPocRequest {
if (occurredFrom != null && occurredTo != null && !occurredFrom.isBefore(occurredTo)) { if (occurredFrom != null && occurredTo != null && !occurredFrom.isBefore(occurredTo)) {
@ -23,5 +27,7 @@ public record EsperPocRequest(
significantDrivingMinutes = significantDrivingMinutes == null ? 3 : Math.max(1, significantDrivingMinutes); significantDrivingMinutes = significantDrivingMinutes == null ? 3 : Math.max(1, significantDrivingMinutes);
mergeGapSeconds = mergeGapSeconds == null ? 60 : Math.max(0, mergeGapSeconds); mergeGapSeconds = mergeGapSeconds == null ? 60 : Math.max(0, mergeGapSeconds);
operatingPeriodSplitRestHours = operatingPeriodSplitRestHours == null ? 7 : Math.max(1, operatingPeriodSplitRestHours); operatingPeriodSplitRestHours = operatingPeriodSplitRestHours == null ? 7 : Math.max(1, operatingPeriodSplitRestHours);
shiftEndMarkPeriodMinutes = shiftEndMarkPeriodMinutes == null ? 420 : Math.max(1, shiftEndMarkPeriodMinutes);
absenceBeginEndMinActivityMinutes = absenceBeginEndMinActivityMinutes == null ? 5 : Math.max(1, absenceBeginEndMinActivityMinutes);
} }
} }

View File

@ -12,14 +12,24 @@ public record EsperPocResultDto(
OffsetDateTime loadedFrom, OffsetDateTime loadedFrom,
OffsetDateTime loadedTo, OffsetDateTime loadedTo,
int rawEventCount, int rawEventCount,
int driverCardRawEventCount,
int vehicleUnitRawEventCount,
int driverCardRawIntervalCount,
int vehicleUnitRawIntervalCount,
int rawIntervalCount, int rawIntervalCount,
int mergedActivityCount, int mergedActivityCount,
int operatingTimePeriodCount, int operatingTimePeriodCount,
int resolvedWorkShiftCount,
int operatingPeriodSplitRestHours, int operatingPeriodSplitRestHours,
int shiftEndMarkPeriodMinutes,
int absenceBeginEndMinActivityMinutes,
EsperActivityMergeMode activityMergeMode,
EsperShiftResolutionMode shiftResolutionMode,
List<RawActivityEventDto> raw, List<RawActivityEventDto> raw,
List<ActivityIntervalDto> rawIntervals, List<ActivityIntervalDto> rawIntervals,
List<ActivityIntervalDto> activities, List<ActivityIntervalDto> activities,
List<OperatingTimePeriodDto> operatingTimePeriods, List<OperatingTimePeriodDto> operatingTimePeriods,
List<ResolvedWorkShiftDto> workingShifts,
DriverWorkSummaryDto workResultPerDriver, DriverWorkSummaryDto workResultPerDriver,
DriverWorkSummaryDto workingOperationTimesPerEmployee, DriverWorkSummaryDto workingOperationTimesPerEmployee,
ShiftDrivingEvaluationDto drivingTimeInterruptionEvaluation, ShiftDrivingEvaluationDto drivingTimeInterruptionEvaluation,

View File

@ -0,0 +1,6 @@
package at.procon.eventhub.esperpoc.dto;
public enum EsperShiftResolutionMode {
JAVA,
ESPER
}

View File

@ -8,6 +8,8 @@ public record RawActivityEventDto(
OffsetDateTime occurredAt, OffsetDateTime occurredAt,
String sourceRowId, String sourceRowId,
String externalSourceEventId, String externalSourceEventId,
String sourceKind,
String extractionCode,
UUID driverEntityId, UUID driverEntityId,
UUID vehicleId, UUID vehicleId,
UUID vehicleRegistrationId, UUID vehicleRegistrationId,

View File

@ -0,0 +1,40 @@
package at.procon.eventhub.esperpoc.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public record ResolvedWorkShiftDto(
int sequenceNumber,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
OffsetDateTime nextShiftStartedAt,
long durationSeconds,
long dailyRestingTimeSeconds,
OffsetDateTime drivingFrom,
OffsetDateTime drivingTo,
OffsetDateTime absenceFrom,
OffsetDateTime absenceTo,
List<UUID> vehicleIds,
List<UUID> vehicleRegistrationIds,
String usedDataKind,
String shiftKind,
boolean beginTimeDeviationInRange,
boolean endTimeDeviationInRange,
boolean noDrivingActivity,
boolean dailyRestingTimeBeforeShiftOver24Hours,
boolean durationOver24Hours,
boolean dailyRestingTimeBetween9And11Hours,
boolean dailyRestingTimeBetween7And9Hours,
int activitiesCount,
int drivingCount,
int availabilityCount,
int workCount,
int breakRestCount,
int unknownCount,
int workingTimeCount,
DriverWorkSummaryDto workingOperationTimes,
ShiftDrivingEvaluationDto drivingTimeInterruptionEvaluation,
List<ActivityIntervalDto> activities
) {
}

View File

@ -16,7 +16,7 @@ public class EsperPocActivityRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
public List<RawActivityEventDto> findDriverCardActivityEvents( public List<RawActivityEventDto> findDriverActivityEvents(
String tenantKey, String tenantKey,
UUID driverEntityId, UUID driverEntityId,
OffsetDateTime occurredFrom, OffsetDateTime occurredFrom,
@ -32,7 +32,14 @@ public class EsperPocActivityRepository {
regexp_replace(event.external_source_event_id, ':(START|END)$', '') regexp_replace(event.external_source_event_id, ':(START|END)$', '')
) as source_row_id, ) as source_row_id,
event.external_source_event_id, event.external_source_event_id,
event.driver_entity_id, source.source_kind,
coalesce(pkg.extraction_code,
case
when source.source_kind = 'VEHICLE_UNIT' then 'VU_ACTIVITY'
else 'CARD_ACTIVITY'
end
) as extraction_code,
event.driver_id,
event.vehicle_id, event.vehicle_id,
event.vehicle_registration_id, event.vehicle_registration_id,
event.event_type, event.event_type,
@ -49,9 +56,12 @@ public class EsperPocActivityRepository {
and detail.detail_type = 'DRIVER_ACTIVITY' and detail.detail_type = 'DRIVER_ACTIVITY'
where pkg.tenant_key = ? where pkg.tenant_key = ?
and source.provider_key = 'TACHOGRAPH' and source.provider_key = 'TACHOGRAPH'
and source.source_kind = 'DRIVER_CARD' and (
and coalesce(pkg.extraction_code, 'CARD_ACTIVITY') = 'CARD_ACTIVITY' (source.source_kind = 'DRIVER_CARD' and coalesce(pkg.extraction_code, 'CARD_ACTIVITY') = 'CARD_ACTIVITY')
and event.driver_entity_id = ? or
(source.source_kind = 'VEHICLE_UNIT' and coalesce(pkg.extraction_code, 'VU_ACTIVITY') = 'VU_ACTIVITY')
)
and event.driver_id = ?
and event.occurred_at >= ? and event.occurred_at >= ?
and event.occurred_at < ? and event.occurred_at < ?
and event.event_domain = 'DRIVER_ACTIVITY' and event.event_domain = 'DRIVER_ACTIVITY'
@ -64,7 +74,9 @@ public class EsperPocActivityRepository {
rs.getObject("occurred_at", OffsetDateTime.class), rs.getObject("occurred_at", OffsetDateTime.class),
rs.getString("source_row_id"), rs.getString("source_row_id"),
rs.getString("external_source_event_id"), rs.getString("external_source_event_id"),
(UUID) rs.getObject("driver_entity_id"), rs.getString("source_kind"),
rs.getString("extraction_code"),
(UUID) rs.getObject("driver_id"),
(UUID) rs.getObject("vehicle_id"), (UUID) rs.getObject("vehicle_id"),
(UUID) rs.getObject("vehicle_registration_id"), (UUID) rs.getObject("vehicle_registration_id"),
rs.getString("event_type"), rs.getString("event_type"),

View File

@ -0,0 +1,24 @@
package at.procon.eventhub.esperpoc.service;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public record EsperActivityIntervalEvent(
UUID driverEntityId,
UUID vehicleId,
UUID vehicleRegistrationId,
String activityType,
String cardSlot,
String cardStatus,
String drivingStatus,
String sourceKind,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
long durationSeconds,
String sourceRowId,
List<String> sourceRowIds,
boolean clippedToRequestedPeriod,
String level
) {
}

View File

@ -0,0 +1,54 @@
package at.procon.eventhub.esperpoc.service;
import at.procon.eventhub.esperpoc.dto.ActivityIntervalDto;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
import java.util.Set;
final class EsperActivitySemantics {
private static final Set<String> ACTIVE_ACTIVITY_TYPES = Set.of("DRIVE", "WORK", "AVAILABILITY");
private EsperActivitySemantics() {
}
static boolean canMerge(ActivityIntervalDto left, ActivityIntervalDto right, Duration tolerance) {
boolean sameDriver = Objects.equals(left.driverEntityId(), right.driverEntityId());
boolean sameActivity = Objects.equals(left.activityType(), right.activityType());
boolean sameSlot = Objects.equals(left.cardSlot(), right.cardSlot());
boolean sameCardStatus = Objects.equals(left.cardStatus(), right.cardStatus());
boolean sameDrivingStatus = Objects.equals(left.drivingStatus(), right.drivingStatus());
boolean sameSourceKind = Objects.equals(left.sourceKind(), right.sourceKind());
long gapSeconds = Duration.between(left.endedAt(), right.startedAt()).getSeconds();
boolean adjacentOrOverlapping = gapSeconds <= tolerance.getSeconds();
return sameDriver && sameActivity && sameSlot && sameCardStatus && sameDrivingStatus && sameSourceKind && adjacentOrOverlapping;
}
static boolean isShiftActivity(ActivityIntervalDto interval) {
return isKnownActivity(interval) && ACTIVE_ACTIVITY_TYPES.contains(interval.activityType());
}
static boolean isRestOrUnknown(ActivityIntervalDto interval) {
return ("BREAK_REST".equals(interval.activityType()) && isKnownActivity(interval))
|| isUnknownActivity(interval);
}
static boolean isKnownActivity(ActivityIntervalDto interval) {
return interval.cardStatus() == null
|| !"NOT_INSERTED".equals(interval.cardStatus())
|| "KNOWN".equals(interval.drivingStatus());
}
static boolean isUnknownActivity(ActivityIntervalDto interval) {
if ("UNKNOWN_ACTIVITY".equals(interval.activityType())) {
return true;
}
return "UNKNOWN".equals(interval.drivingStatus())
|| ("NOT_INSERTED".equals(interval.cardStatus()) && !"KNOWN".equals(interval.drivingStatus()));
}
static LocalDate recordDate(ActivityIntervalDto interval) {
return interval.startedAt().toLocalDate();
}
}

View File

@ -12,12 +12,14 @@ import com.espertech.esper.runtime.client.EPDeployException;
import com.espertech.esper.runtime.client.EPDeployment; import com.espertech.esper.runtime.client.EPDeployment;
import com.espertech.esper.runtime.client.EPRuntime; import com.espertech.esper.runtime.client.EPRuntime;
import com.espertech.esper.runtime.client.EPRuntimeProvider; import com.espertech.esper.runtime.client.EPRuntimeProvider;
import java.time.Duration;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@ -25,7 +27,7 @@ public class EsperDriverActivityEngine {
private static final AtomicLong RUNTIME_COUNTER = new AtomicLong(); private static final AtomicLong RUNTIME_COUNTER = new AtomicLong();
private static final String EPL = """ private static final String INTERVAL_EPL = """
@name('driverCardActivityIntervals') @name('driverCardActivityIntervals')
select select
s.driverEntityId as driverEntityId, s.driverEntityId as driverEntityId,
@ -33,6 +35,9 @@ public class EsperDriverActivityEngine {
s.vehicleRegistrationId as vehicleRegistrationId, s.vehicleRegistrationId as vehicleRegistrationId,
s.eventType as activityType, s.eventType as activityType,
s.cardSlot as cardSlot, s.cardSlot as cardSlot,
s.cardStatus as cardStatus,
s.drivingStatus as drivingStatus,
s.sourceKind as sourceKind,
s.occurredAt as startedAt, s.occurredAt as startedAt,
e.occurredAt as endedAt, e.occurredAt as endedAt,
s.sourceRowId as sourceRowId s.sourceRowId as sourceRowId
@ -48,38 +53,104 @@ public class EsperDriverActivityEngine {
] ]
"""; """;
private static final String INTERVAL_STREAM_EPL = """
@name('driverActivityIntervalStream')
select * from DriverActivityInterval
""";
public List<ActivityIntervalDto> buildIntervals(List<RawActivityEventDto> rawEvents) { public List<ActivityIntervalDto> buildIntervals(List<RawActivityEventDto> rawEvents) {
if (rawEvents == null || rawEvents.isEmpty()) { if (rawEvents == null || rawEvents.isEmpty()) {
return List.of(); return List.of();
} }
List<ActivityIntervalDto> intervals = new ArrayList<>(); List<ActivityIntervalDto> intervals = new ArrayList<>();
executeWithRuntime(
configuration -> configuration.getCommon().addEventType("RawDriverActivityPoint", EsperRawDriverActivityPoint.class),
INTERVAL_EPL,
"driverCardActivityIntervals",
newData -> collectIntervals(newData, intervals),
runtime -> {
List<EsperRawDriverActivityPoint> points = rawEvents.stream()
.sorted(Comparator.comparing(RawActivityEventDto::occurredAt).thenComparing(RawActivityEventDto::lifecycle))
.map(this::toEsperPoint)
.toList();
for (EsperRawDriverActivityPoint point : points) {
runtime.getEventService().sendEventBean(point, "RawDriverActivityPoint");
}
}
);
return intervals.stream()
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt).thenComparing(ActivityIntervalDto::endedAt))
.toList();
}
public List<ActivityIntervalDto> mergeConsecutiveIdenticalActivities(
List<ActivityIntervalDto> intervals,
Duration mergeGapTolerance
) {
List<ActivityIntervalDto> sorted = sortedPositiveIntervals(intervals);
if (sorted.isEmpty()) {
return List.of();
}
MergedActivityCollector collector = new MergedActivityCollector(mergeGapTolerance);
executeIntervalStream(sorted, collector::accept);
return collector.finish();
}
public List<EsperResolvedShiftSpan> resolveShiftSpans(
List<ActivityIntervalDto> intervals,
Duration shiftEndThreshold,
Duration contiguousGapTolerance
) {
List<ActivityIntervalDto> sorted = sortedPositiveIntervals(intervals);
if (sorted.isEmpty()) {
return List.of();
}
ShiftSpanCollector collector = new ShiftSpanCollector(shiftEndThreshold, contiguousGapTolerance);
executeIntervalStream(sorted, collector::accept);
return collector.finish();
}
private void executeIntervalStream(List<ActivityIntervalDto> intervals, Consumer<ActivityIntervalDto> consumer) {
executeWithRuntime(
configuration -> configuration.getCommon().addEventType("DriverActivityInterval", EsperActivityIntervalEvent.class),
INTERVAL_STREAM_EPL,
"driverActivityIntervalStream",
newData -> collectInputIntervals(newData, consumer),
runtime -> {
for (ActivityIntervalDto interval : intervals) {
runtime.getEventService().sendEventBean(toEsperInterval(interval), "DriverActivityInterval");
}
}
);
}
private void executeWithRuntime(
Consumer<Configuration> configurationSetup,
String epl,
String statementName,
Consumer<EventBean[]> listener,
Consumer<EPRuntime> sender
) {
EPRuntime runtime = null; EPRuntime runtime = null;
try { try {
Configuration configuration = new Configuration(); Configuration configuration = new Configuration();
configuration.getCommon().addEventType("RawDriverActivityPoint", EsperRawDriverActivityPoint.class); configurationSetup.accept(configuration);
String runtimeUri = "eventhub-esper-poc-" + RUNTIME_COUNTER.incrementAndGet(); String runtimeUri = "eventhub-esper-poc-" + RUNTIME_COUNTER.incrementAndGet();
runtime = EPRuntimeProvider.getRuntime(runtimeUri, configuration); runtime = EPRuntimeProvider.getRuntime(runtimeUri, configuration);
CompilerArguments arguments = new CompilerArguments(configuration); CompilerArguments arguments = new CompilerArguments(configuration);
EPCompiled compiled = EPCompilerProvider.getCompiler().compile(EPL, arguments); EPCompiled compiled = EPCompilerProvider.getCompiler().compile(epl, arguments);
EPDeployment deployment = runtime.getDeploymentService().deploy(compiled); EPDeployment deployment = runtime.getDeploymentService().deploy(compiled);
runtime.getDeploymentService() runtime.getDeploymentService()
.getStatement(deployment.getDeploymentId(), "driverCardActivityIntervals") .getStatement(deployment.getDeploymentId(), statementName)
.addListener((newData, oldData, statement, rt) -> collectIntervals(newData, intervals)); .addListener((newData, oldData, statement, rt) -> listener.accept(newData));
List<EsperRawDriverActivityPoint> points = rawEvents.stream() sender.accept(runtime);
.sorted(Comparator.comparing(RawActivityEventDto::occurredAt).thenComparing(RawActivityEventDto::lifecycle))
.map(this::toEsperPoint)
.toList();
for (EsperRawDriverActivityPoint point : points) {
runtime.getEventService().sendEventBean(point, "RawDriverActivityPoint");
}
return intervals.stream()
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt).thenComparing(ActivityIntervalDto::endedAt))
.toList();
} catch (EPCompileException | EPDeployException e) { } catch (EPCompileException | EPDeployException e) {
throw new IllegalStateException("Cannot compile/deploy Esper PoC EPL", e); throw new IllegalStateException("Cannot compile/deploy Esper PoC EPL", e);
} finally { } finally {
@ -102,6 +173,9 @@ public class EsperDriverActivityEngine {
(UUID) event.get("vehicleRegistrationId"), (UUID) event.get("vehicleRegistrationId"),
(String) event.get("activityType"), (String) event.get("activityType"),
(String) event.get("cardSlot"), (String) event.get("cardSlot"),
(String) event.get("cardStatus"),
(String) event.get("drivingStatus"),
(String) event.get("sourceKind"),
startedAt, startedAt,
endedAt, endedAt,
(String) event.get("sourceRowId") (String) event.get("sourceRowId")
@ -109,6 +183,32 @@ public class EsperDriverActivityEngine {
} }
} }
private void collectInputIntervals(EventBean[] newData, Consumer<ActivityIntervalDto> consumer) {
if (newData == null) {
return;
}
for (EventBean event : newData) {
EsperActivityIntervalEvent interval = (EsperActivityIntervalEvent) event.getUnderlying();
consumer.accept(new ActivityIntervalDto(
interval.driverEntityId(),
interval.vehicleId(),
interval.vehicleRegistrationId(),
interval.activityType(),
interval.cardSlot(),
interval.cardStatus(),
interval.drivingStatus(),
interval.sourceKind(),
interval.startedAt(),
interval.endedAt(),
interval.durationSeconds(),
interval.sourceRowId(),
interval.sourceRowIds(),
interval.clippedToRequestedPeriod(),
interval.level()
));
}
}
private EsperRawDriverActivityPoint toEsperPoint(RawActivityEventDto event) { private EsperRawDriverActivityPoint toEsperPoint(RawActivityEventDto event) {
return new EsperRawDriverActivityPoint( return new EsperRawDriverActivityPoint(
event.eventId(), event.eventId(),
@ -120,7 +220,186 @@ public class EsperDriverActivityEngine {
event.vehicleRegistrationId(), event.vehicleRegistrationId(),
event.activityType(), event.activityType(),
event.lifecycle(), event.lifecycle(),
event.cardSlot() event.cardSlot(),
event.cardStatus(),
event.drivingStatus(),
event.sourceKind()
); );
} }
private EsperActivityIntervalEvent toEsperInterval(ActivityIntervalDto interval) {
return new EsperActivityIntervalEvent(
interval.driverEntityId(),
interval.vehicleId(),
interval.vehicleRegistrationId(),
interval.activityType(),
interval.cardSlot(),
interval.cardStatus(),
interval.drivingStatus(),
interval.sourceKind(),
interval.startedAt(),
interval.endedAt(),
interval.durationSeconds(),
interval.sourceRowId(),
interval.sourceRowIds(),
interval.clippedToRequestedPeriod(),
interval.level()
);
}
private List<ActivityIntervalDto> sortedPositiveIntervals(List<ActivityIntervalDto> intervals) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
return intervals.stream()
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt)
.thenComparing(ActivityIntervalDto::endedAt)
.thenComparing(ActivityIntervalDto::activityType, Comparator.nullsLast(String::compareTo)))
.toList();
}
private static final class MergedActivityCollector {
private final Duration mergeGapTolerance;
private final List<ActivityIntervalDto> merged = new ArrayList<>();
private ActivityIntervalDto current;
private List<String> currentSources = List.of();
private MergedActivityCollector(Duration mergeGapTolerance) {
this.mergeGapTolerance = mergeGapTolerance;
}
private void accept(ActivityIntervalDto next) {
if (current == null) {
current = next;
currentSources = new ArrayList<>(next.sourceRowIds());
return;
}
if (EsperActivitySemantics.canMerge(current, next, mergeGapTolerance)) {
currentSources.addAll(next.sourceRowIds());
OffsetDateTime newEndedAt = current.endedAt().isAfter(next.endedAt()) ? current.endedAt() : next.endedAt();
current = new ActivityIntervalDto(
current.driverEntityId(),
current.vehicleId() == null ? next.vehicleId() : current.vehicleId(),
current.vehicleRegistrationId() == null ? next.vehicleRegistrationId() : current.vehicleRegistrationId(),
current.activityType(),
current.cardSlot(),
current.cardStatus(),
current.drivingStatus(),
current.sourceKind(),
current.startedAt(),
newEndedAt,
Duration.between(current.startedAt(), newEndedAt).getSeconds(),
current.sourceRowId(),
List.copyOf(currentSources),
current.clippedToRequestedPeriod() || next.clippedToRequestedPeriod(),
"MERGED_ACTIVITY"
);
return;
}
merged.add(current.asMerged(List.copyOf(currentSources)));
current = next;
currentSources = new ArrayList<>(next.sourceRowIds());
}
private List<ActivityIntervalDto> finish() {
if (current != null) {
merged.add(current.asMerged(List.copyOf(currentSources)));
}
return List.copyOf(merged);
}
}
private static final class ShiftSpanCollector {
private final Duration shiftEndThreshold;
private final Duration contiguousGapTolerance;
private final List<ActivityIntervalDto> seen = new ArrayList<>();
private final List<EsperResolvedShiftSpan> spans = new ArrayList<>();
private OffsetDateTime shiftBegin;
private long accumulatedRestSeconds;
private OffsetDateTime prevActivityEnd;
private OffsetDateTime pendingShiftEndTriggerAt;
private ShiftSpanCollector(Duration shiftEndThreshold, Duration contiguousGapTolerance) {
this.shiftEndThreshold = shiftEndThreshold;
this.contiguousGapTolerance = contiguousGapTolerance;
}
private void accept(ActivityIntervalDto interval) {
seen.add(interval);
if (shiftBegin == null) {
if (EsperActivitySemantics.isShiftActivity(interval)) {
shiftBegin = interval.startedAt();
}
prevActivityEnd = interval.endedAt();
return;
}
if (!shiftBegin.isBefore(interval.startedAt())) {
prevActivityEnd = interval.endedAt();
return;
}
if (pendingShiftEndTriggerAt != null && EsperActivitySemantics.isShiftActivity(interval)) {
appendShiftSpan(shiftBegin, interval.startedAt());
shiftBegin = interval.startedAt();
pendingShiftEndTriggerAt = null;
accumulatedRestSeconds = 0;
}
if (EsperActivitySemantics.isRestOrUnknown(interval)) {
long gapSeconds = prevActivityEnd == null ? 0 : Math.max(0, Duration.between(prevActivityEnd, interval.startedAt()).getSeconds());
boolean contiguous = prevActivityEnd != null
&& Math.abs(Duration.between(prevActivityEnd, interval.startedAt()).getSeconds()) <= contiguousGapTolerance.getSeconds();
boolean crossedRecordDate = prevActivityEnd != null
&& !interval.startedAt().toLocalDate().equals(prevActivityEnd.toLocalDate());
if (contiguous) {
accumulatedRestSeconds += interval.durationSeconds();
} else if (crossedRecordDate) {
accumulatedRestSeconds = interval.durationSeconds() + gapSeconds;
} else {
accumulatedRestSeconds = interval.durationSeconds();
}
if (accumulatedRestSeconds >= shiftEndThreshold.getSeconds() && pendingShiftEndTriggerAt == null) {
pendingShiftEndTriggerAt = interval.startedAt();
}
} else if (EsperActivitySemantics.isShiftActivity(interval)) {
long gapSeconds = prevActivityEnd == null ? 0 : Math.max(0, Duration.between(prevActivityEnd, interval.startedAt()).getSeconds());
boolean crossedRecordDate = prevActivityEnd != null
&& !interval.startedAt().toLocalDate().equals(prevActivityEnd.toLocalDate());
if (crossedRecordDate && accumulatedRestSeconds + gapSeconds >= shiftEndThreshold.getSeconds()) {
appendShiftSpan(shiftBegin, interval.startedAt());
shiftBegin = interval.startedAt();
}
accumulatedRestSeconds = 0;
pendingShiftEndTriggerAt = null;
}
prevActivityEnd = interval.endedAt();
}
private List<EsperResolvedShiftSpan> finish() {
if (shiftBegin != null) {
appendShiftSpan(shiftBegin, null);
}
return List.copyOf(spans);
}
private void appendShiftSpan(OffsetDateTime currentShiftBegin, OffsetDateTime nextShiftBegin) {
if (currentShiftBegin == null) {
return;
}
OffsetDateTime shiftEnd = seen.stream()
.filter(interval -> !interval.startedAt().isBefore(currentShiftBegin))
.filter(interval -> nextShiftBegin == null || interval.startedAt().isBefore(nextShiftBegin))
.filter(EsperActivitySemantics::isShiftActivity)
.map(ActivityIntervalDto::endedAt)
.max(OffsetDateTime::compareTo)
.orElse(null);
if (shiftEnd == null || !shiftEnd.isAfter(currentShiftBegin)) {
return;
}
long restSeconds = nextShiftBegin == null ? 0 : Math.max(0, Duration.between(shiftEnd, nextShiftBegin).getSeconds());
spans.add(new EsperResolvedShiftSpan(currentShiftBegin, shiftEnd, nextShiftBegin, restSeconds));
}
}
} }

View File

@ -1,12 +1,16 @@
package at.procon.eventhub.esperpoc.service; package at.procon.eventhub.esperpoc.service;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.esperpoc.dto.ActivityIntervalDto; import at.procon.eventhub.esperpoc.dto.ActivityIntervalDto;
import at.procon.eventhub.esperpoc.dto.DriverWorkSummaryDto; import at.procon.eventhub.esperpoc.dto.DriverWorkSummaryDto;
import at.procon.eventhub.esperpoc.dto.DrivingInterruptionDto; import at.procon.eventhub.esperpoc.dto.DrivingInterruptionDto;
import at.procon.eventhub.esperpoc.dto.EsperActivityMergeMode;
import at.procon.eventhub.esperpoc.dto.EsperPocRequest; import at.procon.eventhub.esperpoc.dto.EsperPocRequest;
import at.procon.eventhub.esperpoc.dto.EsperPocResultDto; import at.procon.eventhub.esperpoc.dto.EsperPocResultDto;
import at.procon.eventhub.esperpoc.dto.EsperShiftResolutionMode;
import at.procon.eventhub.esperpoc.dto.OperatingTimePeriodDto; import at.procon.eventhub.esperpoc.dto.OperatingTimePeriodDto;
import at.procon.eventhub.esperpoc.dto.RawActivityEventDto; import at.procon.eventhub.esperpoc.dto.RawActivityEventDto;
import at.procon.eventhub.esperpoc.dto.ResolvedWorkShiftDto;
import at.procon.eventhub.esperpoc.dto.ShiftDrivingEvaluationDto; import at.procon.eventhub.esperpoc.dto.ShiftDrivingEvaluationDto;
import at.procon.eventhub.esperpoc.persistence.EsperPocActivityRepository; import at.procon.eventhub.esperpoc.persistence.EsperPocActivityRepository;
import java.time.Duration; import java.time.Duration;
@ -15,58 +19,140 @@ import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class EsperPocDriverCardActivityService { public class EsperPocDriverCardActivityService {
private static final Duration SHIFT_SCAN_CONTIGUOUS_GAP = Duration.ofMinutes(1);
private static final Logger log = LoggerFactory.getLogger(EsperPocDriverCardActivityService.class);
private final EsperPocActivityRepository activityRepository; private final EsperPocActivityRepository activityRepository;
private final EsperDriverActivityEngine esperEngine; private final EsperDriverActivityEngine esperEngine;
private final EventHubProperties properties;
public EsperPocDriverCardActivityService( public EsperPocDriverCardActivityService(
EsperPocActivityRepository activityRepository, EsperPocActivityRepository activityRepository,
EsperDriverActivityEngine esperEngine EsperDriverActivityEngine esperEngine
) {
this(activityRepository, esperEngine, null);
}
@Autowired
public EsperPocDriverCardActivityService(
EsperPocActivityRepository activityRepository,
EsperDriverActivityEngine esperEngine,
EventHubProperties properties
) { ) {
this.activityRepository = activityRepository; this.activityRepository = activityRepository;
this.esperEngine = esperEngine; this.esperEngine = esperEngine;
this.properties = properties;
} }
public EsperPocResultDto evaluate(EsperPocRequest request) { public EsperPocResultDto evaluate(EsperPocRequest request) {
long startedNanos = System.nanoTime();
EsperActivityMergeMode activityMergeMode = resolveActivityMergeMode(request);
EsperShiftResolutionMode shiftResolutionMode = resolveShiftResolutionMode(request);
OffsetDateTime requestedFrom = utc(request.occurredFrom()); OffsetDateTime requestedFrom = utc(request.occurredFrom());
OffsetDateTime requestedTo = utc(request.occurredTo()); OffsetDateTime requestedTo = utc(request.occurredTo());
OffsetDateTime loadedFrom = requestedFrom.minusHours(request.guardHours()); OffsetDateTime loadedFrom = requestedFrom.minusHours(request.guardHours());
OffsetDateTime loadedTo = requestedTo.plusHours(request.guardHours()); OffsetDateTime loadedTo = requestedTo.plusHours(request.guardHours());
List<RawActivityEventDto> rawEvents = activityRepository.findDriverCardActivityEvents( long dbStartedNanos = System.nanoTime();
List<RawActivityEventDto> rawEvents = activityRepository.findDriverActivityEvents(
request.tenantKey(), request.tenantKey(),
request.driverEntityId(), request.driverEntityId(),
loadedFrom, loadedFrom,
loadedTo loadedTo
); );
List<ActivityIntervalDto> rawIntervals = esperEngine.buildIntervals(rawEvents); long dbElapsedMs = elapsedMillis(dbStartedNanos);
List<RawActivityEventDto> driverCardRawEvents = rawEvents.stream()
.filter(event -> "DRIVER_CARD".equals(event.sourceKind()))
.toList();
List<RawActivityEventDto> vehicleUnitRawEvents = rawEvents.stream()
.filter(event -> "VEHICLE_UNIT".equals(event.sourceKind()))
.toList();
// Merge in the full guard window first. This is important for long BREAK_REST detection: long cardIntervalsStartedNanos = System.nanoTime();
// a rest crossing the requested period boundary must keep its full guard-window duration. List<ActivityIntervalDto> driverCardRawIntervals = esperEngine.buildIntervals(driverCardRawEvents);
List<ActivityIntervalDto> mergedLoadedActivities = mergeConsecutiveIdenticalActivities( long cardIntervalsElapsedMs = elapsedMillis(cardIntervalsStartedNanos);
rawIntervals, long vuIntervalsStartedNanos = System.nanoTime();
Duration.ofSeconds(request.mergeGapSeconds()) List<ActivityIntervalDto> vehicleUnitRawIntervals = esperEngine.buildIntervals(vehicleUnitRawEvents);
long vuIntervalsElapsedMs = elapsedMillis(vuIntervalsStartedNanos);
long vuGapFillStartedNanos = System.nanoTime();
List<ActivityIntervalDto> resolvedLoadedIntervals = resolveVuFillGaps(
driverCardRawIntervals,
vehicleUnitRawIntervals
); );
long vuGapFillElapsedMs = elapsedMillis(vuGapFillStartedNanos);
long mergeStartedNanos = System.nanoTime();
List<ActivityIntervalDto> mergedLoadedActivities = mergeActivities(
resolvedLoadedIntervals,
Duration.ofSeconds(request.mergeGapSeconds()),
activityMergeMode
);
long mergeElapsedMs = elapsedMillis(mergeStartedNanos);
long summaryStartedNanos = System.nanoTime();
List<ActivityIntervalDto> mergedActivities = clipToPeriod(mergedLoadedActivities, requestedFrom, requestedTo); List<ActivityIntervalDto> mergedActivities = clipToPeriod(mergedLoadedActivities, requestedFrom, requestedTo);
DriverWorkSummaryDto summary = summarize(request, requestedFrom, requestedTo, mergedActivities); DriverWorkSummaryDto summary = summarize(request, requestedFrom, requestedTo, mergedActivities);
ShiftDrivingEvaluationDto drivingEvaluation = evaluateSignificantDriving( long summaryElapsedMs = elapsedMillis(summaryStartedNanos);
mergedActivities, long operatingPeriodsStartedNanos = System.nanoTime();
request.significantDrivingMinutes()
);
List<OperatingTimePeriodDto> operatingTimePeriods = buildOperatingTimePeriods( List<OperatingTimePeriodDto> operatingTimePeriods = buildOperatingTimePeriods(
request, request,
requestedFrom, requestedFrom,
requestedTo, requestedTo,
mergedLoadedActivities mergedLoadedActivities
); );
long operatingPeriodsElapsedMs = elapsedMillis(operatingPeriodsStartedNanos);
long workingShiftsStartedNanos = System.nanoTime();
List<ResolvedWorkShiftDto> workingShifts = buildResolvedWorkShifts(
request,
requestedFrom,
requestedTo,
resolvedLoadedIntervals,
activityMergeMode,
shiftResolutionMode
);
long workingShiftsElapsedMs = elapsedMillis(workingShiftsStartedNanos);
long totalElapsedMs = elapsedMillis(startedNanos);
log.info("Esper PoC working-shift evaluation tenant={} driverId={} requestedFrom={} requestedTo={} loadedFrom={} loadedTo={} mergeMode={} shiftMode={} rawEvents={} cardRawEvents={} vuRawEvents={} cardIntervals={} vuIntervals={} resolvedIntervals={} mergedActivities={} operatingPeriods={} workingShifts={} timingsMs={{dbRetrieve={}, cardIntervalEsper={}, vuIntervalEsper={}, vuGapFill={}, merge={}, summaryAndDriving={}, operatingPeriods={}, workingShifts={}, total={}}}",
request.tenantKey(),
request.driverEntityId(),
requestedFrom,
requestedTo,
loadedFrom,
loadedTo,
activityMergeMode,
shiftResolutionMode,
rawEvents.size(),
driverCardRawEvents.size(),
vehicleUnitRawEvents.size(),
driverCardRawIntervals.size(),
vehicleUnitRawIntervals.size(),
resolvedLoadedIntervals.size(),
mergedActivities.size(),
operatingTimePeriods.size(),
workingShifts.size(),
dbElapsedMs,
cardIntervalsElapsedMs,
vuIntervalsElapsedMs,
vuGapFillElapsedMs,
mergeElapsedMs,
summaryElapsedMs,
operatingPeriodsElapsedMs,
workingShiftsElapsedMs,
totalElapsedMs);
return new EsperPocResultDto( return new EsperPocResultDto(
request.tenantKey(), request.tenantKey(),
@ -76,18 +162,28 @@ public class EsperPocDriverCardActivityService {
loadedFrom, loadedFrom,
loadedTo, loadedTo,
rawEvents.size(), rawEvents.size(),
rawIntervals.size(), driverCardRawEvents.size(),
vehicleUnitRawEvents.size(),
driverCardRawIntervals.size(),
vehicleUnitRawIntervals.size(),
resolvedLoadedIntervals.size(),
mergedActivities.size(), mergedActivities.size(),
operatingTimePeriods.size(), operatingTimePeriods.size(),
workingShifts.size(),
request.operatingPeriodSplitRestHours(), request.operatingPeriodSplitRestHours(),
request.shiftEndMarkPeriodMinutes(),
request.absenceBeginEndMinActivityMinutes(),
activityMergeMode,
shiftResolutionMode,
rawEvents, rawEvents,
rawIntervals, resolvedLoadedIntervals,
mergedActivities, mergedActivities,
operatingTimePeriods, operatingTimePeriods,
workingShifts,
summary, summary,
summary, summary,
drivingEvaluation, null,
notes(request) notes(request, activityMergeMode, shiftResolutionMode)
); );
} }
@ -98,10 +194,7 @@ public class EsperPocDriverCardActivityService {
if (intervals == null || intervals.isEmpty()) { if (intervals == null || intervals.isEmpty()) {
return List.of(); return List.of();
} }
List<ActivityIntervalDto> sorted = intervals.stream() List<ActivityIntervalDto> sorted = sortedPositiveIntervals(intervals);
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt).thenComparing(ActivityIntervalDto::endedAt))
.toList();
List<ActivityIntervalDto> result = new ArrayList<>(); List<ActivityIntervalDto> result = new ArrayList<>();
ActivityIntervalDto current = null; ActivityIntervalDto current = null;
List<String> currentSources = new ArrayList<>(); List<String> currentSources = new ArrayList<>();
@ -111,7 +204,7 @@ public class EsperPocDriverCardActivityService {
currentSources = new ArrayList<>(next.sourceRowIds()); currentSources = new ArrayList<>(next.sourceRowIds());
continue; continue;
} }
if (canMerge(current, next, mergeGapTolerance)) { if (EsperActivitySemantics.canMerge(current, next, mergeGapTolerance)) {
currentSources.addAll(next.sourceRowIds()); currentSources.addAll(next.sourceRowIds());
OffsetDateTime newEndedAt = max(current.endedAt(), next.endedAt()); OffsetDateTime newEndedAt = max(current.endedAt(), next.endedAt());
current = new ActivityIntervalDto( current = new ActivityIntervalDto(
@ -120,6 +213,9 @@ public class EsperPocDriverCardActivityService {
current.vehicleRegistrationId() == null ? next.vehicleRegistrationId() : current.vehicleRegistrationId(), current.vehicleRegistrationId() == null ? next.vehicleRegistrationId() : current.vehicleRegistrationId(),
current.activityType(), current.activityType(),
current.cardSlot(), current.cardSlot(),
current.cardStatus(),
current.drivingStatus(),
current.sourceKind(),
current.startedAt(), current.startedAt(),
newEndedAt, newEndedAt,
Duration.between(current.startedAt(), newEndedAt).getSeconds(), Duration.between(current.startedAt(), newEndedAt).getSeconds(),
@ -147,13 +243,9 @@ public class EsperPocDriverCardActivityService {
List<ActivityIntervalDto> mergedLoadedActivities List<ActivityIntervalDto> mergedLoadedActivities
) { ) {
Duration splitRestThreshold = Duration.ofHours(request.operatingPeriodSplitRestHours()); Duration splitRestThreshold = Duration.ofHours(request.operatingPeriodSplitRestHours());
List<ActivityIntervalDto> sorted = mergedLoadedActivities.stream() List<ActivityIntervalDto> sorted = sortedPositiveIntervals(mergedLoadedActivities);
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt).thenComparing(ActivityIntervalDto::endedAt))
.toList();
List<ActivityIntervalDto> longRests = sorted.stream() List<ActivityIntervalDto> longRests = sorted.stream()
.filter(interval -> isOperatingPeriodSplitRest(interval, splitRestThreshold)) .filter(interval -> isOperatingPeriodSplitRest(interval, splitRestThreshold))
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt))
.toList(); .toList();
List<OperatingTimePeriodDto> result = new ArrayList<>(); List<OperatingTimePeriodDto> result = new ArrayList<>();
@ -213,53 +305,367 @@ public class EsperPocDriverCardActivityService {
return result; return result;
} }
private OperatingTimePeriodDto buildOperatingPeriod( public List<ResolvedWorkShiftDto> buildResolvedWorkShifts(
int sequenceNumber,
EsperPocRequest request, EsperPocRequest request,
OffsetDateTime spanFrom, OffsetDateTime requestedFrom,
OffsetDateTime spanTo, OffsetDateTime requestedTo,
ActivityIntervalDto splitStartedAfterLongRest, List<ActivityIntervalDto> resolvedLoadedIntervals
ActivityIntervalDto splitEndedByLongRest,
List<ActivityIntervalDto> allActivities
) { ) {
List<ActivityIntervalDto> activities = allActivities.stream() return buildResolvedWorkShifts(
.filter(activity -> activity.startedAt().isBefore(spanTo) && activity.endedAt().isAfter(spanFrom)) request,
.map(activity -> { requestedFrom,
OffsetDateTime start = max(activity.startedAt(), spanFrom); requestedTo,
OffsetDateTime end = min(activity.endedAt(), spanTo); resolvedLoadedIntervals,
if (!end.isAfter(start)) { resolveActivityMergeMode(request),
return null; resolveShiftResolutionMode(request)
} );
boolean clipped = !start.equals(activity.startedAt()) || !end.equals(activity.endedAt()); }
return activity.withTime(start, end, clipped);
})
.filter(Objects::nonNull)
.filter(activity -> activity.endedAt().isAfter(activity.startedAt()))
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt).thenComparing(ActivityIntervalDto::endedAt))
.toList();
if (activities.isEmpty()) { public List<ResolvedWorkShiftDto> buildResolvedWorkShifts(
return null; EsperPocRequest request,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo,
List<ActivityIntervalDto> resolvedLoadedIntervals,
EsperActivityMergeMode activityMergeMode,
EsperShiftResolutionMode shiftResolutionMode
) {
long startedNanos = System.nanoTime();
List<ActivityIntervalDto> sorted = sortedPositiveIntervals(resolvedLoadedIntervals);
if (sorted.isEmpty()) {
log.info("Esper PoC work-shift resolution requestedFrom={} requestedTo={} shiftMode={} sourceIntervals=0 resolvedShifts=0 timingsMs={{resolveSpans=0, enrichShifts=0, total={}}}",
requestedFrom,
requestedTo,
shiftResolutionMode,
elapsedMillis(startedNanos));
return List.of();
} }
OffsetDateTime startedAt = activities.get(0).startedAt(); long resolveSpansStartedNanos = System.nanoTime();
OffsetDateTime endedAt = activities.get(activities.size() - 1).endedAt(); List<EsperResolvedShiftSpan> shiftSpans = resolveShiftSpans(sorted, request, shiftResolutionMode);
DriverWorkSummaryDto summary = summarize(request, startedAt, endedAt, activities); long resolveSpansElapsedMs = elapsedMillis(resolveSpansStartedNanos);
ShiftDrivingEvaluationDto drivingEvaluation = evaluateSignificantDriving( if (shiftSpans.isEmpty()) {
activities, log.info("Esper PoC work-shift resolution requestedFrom={} requestedTo={} shiftMode={} sourceIntervals={} resolvedShiftSpans=0 resolvedShifts=0 timingsMs={{resolveSpans={}, enrichShifts=0, total={}}}",
request.significantDrivingMinutes() requestedFrom,
); requestedTo,
return new OperatingTimePeriodDto( shiftResolutionMode,
sequenceNumber, sorted.size(),
startedAt, resolveSpansElapsedMs,
endedAt, elapsedMillis(startedNanos));
Duration.between(startedAt, endedAt).getSeconds(), return List.of();
splitStartedAfterLongRest, }
splitEndedByLongRest,
activities, ZoneOffset evaluationOffset = request.occurredFrom().getOffset();
summary, ShiftDeviationStats deviationStats = calculateShiftDeviationStats(shiftSpans, evaluationOffset);
drivingEvaluation List<ResolvedWorkShiftDto> result = new ArrayList<>();
); int sequenceNumber = 1;
long enrichShiftsStartedNanos = System.nanoTime();
for (int index = 0; index < shiftSpans.size(); index++) {
EsperResolvedShiftSpan span = shiftSpans.get(index);
if (!span.endedAt().isAfter(requestedFrom) || !span.startedAt().isBefore(requestedTo)) {
continue;
}
List<ActivityIntervalDto> rawShiftActivities = clipToPeriod(sorted, span.startedAt(), span.endedAt());
if (rawShiftActivities.isEmpty()) {
continue;
}
List<ActivityIntervalDto> mergedShiftActivities = mergeActivities(
rawShiftActivities,
Duration.ofSeconds(request.mergeGapSeconds()),
activityMergeMode
);
DriverWorkSummaryDto summary = summarize(request, span.startedAt(), span.endedAt(), mergedShiftActivities);
ShiftDrivingEvaluationDto drivingEvaluation = evaluateSignificantDriving(
mergedShiftActivities,
request.significantDrivingMinutes()
);
ShiftDeviationSnapshot deviation = deviationStats.snapshot(span, evaluationOffset);
result.add(new ResolvedWorkShiftDto(
sequenceNumber++,
span.startedAt(),
span.endedAt(),
span.nextShiftStartedAt(),
span.durationSeconds(),
span.dailyRestingTimeSeconds(),
firstDrivingStart(rawShiftActivities),
lastDrivingEnd(rawShiftActivities),
firstDrivingStart(rawShiftActivities, request.absenceBeginEndMinActivityMinutes()),
lastDrivingEnd(rawShiftActivities, request.absenceBeginEndMinActivityMinutes()),
distinctVehicleIds(rawShiftActivities),
distinctVehicleRegistrationIds(rawShiftActivities),
determineUsedDataKind(rawShiftActivities),
determineShiftKind(span, evaluationOffset),
deviation.beginInRange(),
deviation.endInRange(),
summary.drivingSeconds() == 0,
index > 0 && shiftSpans.get(index - 1).dailyRestingTimeSeconds() > Duration.ofHours(24).getSeconds(),
span.durationSeconds() > Duration.ofHours(24).getSeconds(),
span.dailyRestingTimeSeconds() >= Duration.ofHours(9).getSeconds()
&& span.dailyRestingTimeSeconds() <= Duration.ofHours(11).getSeconds(),
span.dailyRestingTimeSeconds() >= Duration.ofHours(7).getSeconds()
&& span.dailyRestingTimeSeconds() <= Duration.ofHours(9).getSeconds(),
rawShiftActivities.size(),
countActivities(rawShiftActivities, "DRIVE"),
countActivities(rawShiftActivities, "AVAILABILITY"),
countActivities(rawShiftActivities, "WORK"),
countActivities(rawShiftActivities, "BREAK_REST"),
countUnknownActivities(rawShiftActivities),
countWorkingActivities(rawShiftActivities),
summary,
drivingEvaluation,
rawShiftActivities
));
}
long enrichShiftsElapsedMs = elapsedMillis(enrichShiftsStartedNanos);
log.info("Esper PoC work-shift resolution requestedFrom={} requestedTo={} shiftMode={} sourceIntervals={} resolvedShiftSpans={} resolvedShifts={} timingsMs={{resolveSpans={}, enrichShifts={}, total={}}}",
requestedFrom,
requestedTo,
shiftResolutionMode,
sorted.size(),
shiftSpans.size(),
result.size(),
resolveSpansElapsedMs,
enrichShiftsElapsedMs,
elapsedMillis(startedNanos));
return result;
}
List<ActivityIntervalDto> resolveVuFillGaps(
List<ActivityIntervalDto> driverCardRawIntervals,
List<ActivityIntervalDto> vehicleUnitRawIntervals
) {
List<ActivityIntervalDto> driverCard = sortedPositiveIntervals(driverCardRawIntervals);
List<ActivityIntervalDto> vehicleUnit = sortedPositiveIntervals(vehicleUnitRawIntervals);
if (driverCard.isEmpty()) {
return vehicleUnit;
}
if (vehicleUnit.isEmpty()) {
return driverCard;
}
List<ActivityIntervalDto> resolved = new ArrayList<>(driverCard);
for (ActivityIntervalDto vuInterval : vehicleUnit) {
resolved.addAll(subtractCoverage(vuInterval, driverCard));
}
return resolved.stream()
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt)
.thenComparing(ActivityIntervalDto::endedAt)
.thenComparing(ActivityIntervalDto::sourceKind, Comparator.nullsLast(String::compareTo)))
.toList();
}
private List<ActivityIntervalDto> mergeActivities(
List<ActivityIntervalDto> intervals,
Duration mergeGapTolerance,
EsperActivityMergeMode mode
) {
if (mode == EsperActivityMergeMode.ESPER) {
return requireEsperEngine().mergeConsecutiveIdenticalActivities(intervals, mergeGapTolerance);
}
return mergeConsecutiveIdenticalActivities(intervals, mergeGapTolerance);
}
private List<ActivityIntervalDto> subtractCoverage(
ActivityIntervalDto candidate,
List<ActivityIntervalDto> coverage
) {
List<ActivityIntervalDto> result = new ArrayList<>();
OffsetDateTime cursor = candidate.startedAt();
for (ActivityIntervalDto covered : coverage) {
if (!covered.endedAt().isAfter(cursor)) {
continue;
}
if (!covered.startedAt().isBefore(candidate.endedAt())) {
break;
}
OffsetDateTime overlapStart = max(cursor, covered.startedAt());
if (overlapStart.isAfter(cursor)) {
result.add(candidate.withTime(cursor, overlapStart, candidate.clippedToRequestedPeriod()));
}
if (covered.endedAt().isAfter(cursor)) {
cursor = max(cursor, covered.endedAt());
}
if (!candidate.endedAt().isAfter(cursor)) {
break;
}
}
if (candidate.endedAt().isAfter(cursor)) {
result.add(candidate.withTime(cursor, candidate.endedAt(), candidate.clippedToRequestedPeriod()));
}
return result.stream()
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
.toList();
}
private List<EsperResolvedShiftSpan> resolveShiftSpans(
List<ActivityIntervalDto> sorted,
EsperPocRequest request,
EsperShiftResolutionMode mode
) {
if (mode == EsperShiftResolutionMode.ESPER) {
return requireEsperEngine().resolveShiftSpans(
sorted,
Duration.ofMinutes(request.shiftEndMarkPeriodMinutes()),
SHIFT_SCAN_CONTIGUOUS_GAP
);
}
ActivityIntervalDto firstShiftActivity = sorted.stream()
.filter(EsperActivitySemantics::isShiftActivity)
.findFirst()
.orElse(null);
if (firstShiftActivity == null) {
return List.of();
}
List<EsperResolvedShiftSpan> result = new ArrayList<>();
OffsetDateTime shiftBegin = firstShiftActivity.startedAt();
long accumulatedRestSeconds = 0;
OffsetDateTime prevActivityEnd = null;
long shiftEndThresholdSeconds = Duration.ofMinutes(request.shiftEndMarkPeriodMinutes()).getSeconds();
for (ActivityIntervalDto interval : sorted) {
if (!shiftBegin.isBefore(interval.startedAt())) {
prevActivityEnd = interval.endedAt();
continue;
}
if (EsperActivitySemantics.isRestOrUnknown(interval)) {
if (prevActivityEnd != null
&& Math.abs(Duration.between(prevActivityEnd, interval.startedAt()).getSeconds()) <= SHIFT_SCAN_CONTIGUOUS_GAP.getSeconds()) {
accumulatedRestSeconds += interval.durationSeconds();
} else if (prevActivityEnd != null
&& !Objects.equals(EsperActivitySemantics.recordDate(interval), prevActivityEnd.toLocalDate())) {
accumulatedRestSeconds = interval.durationSeconds() + Math.max(0, Duration.between(prevActivityEnd, interval.startedAt()).getSeconds());
} else {
accumulatedRestSeconds = interval.durationSeconds();
}
if (accumulatedRestSeconds >= shiftEndThresholdSeconds) {
OffsetDateTime nextShiftBegin = findNextActiveStartAfter(sorted, interval.startedAt());
appendShiftSpan(result, sorted, shiftBegin, nextShiftBegin);
shiftBegin = nextShiftBegin;
accumulatedRestSeconds = 0;
}
} else if (EsperActivitySemantics.isShiftActivity(interval)) {
if (prevActivityEnd != null
&& !Objects.equals(EsperActivitySemantics.recordDate(interval), prevActivityEnd.toLocalDate())
&& accumulatedRestSeconds + Math.max(0, Duration.between(prevActivityEnd, interval.startedAt()).getSeconds()) >= shiftEndThresholdSeconds) {
OffsetDateTime nextShiftBegin = interval.startedAt();
appendShiftSpan(result, sorted, shiftBegin, nextShiftBegin);
shiftBegin = nextShiftBegin;
}
accumulatedRestSeconds = 0;
}
prevActivityEnd = interval.endedAt();
}
if (shiftBegin != null) {
appendShiftSpan(result, sorted, shiftBegin, null);
}
return result;
}
private void appendShiftSpan(
List<EsperResolvedShiftSpan> target,
List<ActivityIntervalDto> sorted,
OffsetDateTime shiftBegin,
OffsetDateTime nextShiftBegin
) {
if (shiftBegin == null) {
return;
}
OffsetDateTime shiftEnd = findLastActiveEnd(sorted, shiftBegin, nextShiftBegin);
if (shiftEnd == null || !shiftEnd.isAfter(shiftBegin)) {
return;
}
long restSeconds = nextShiftBegin == null ? 0 : Math.max(0, Duration.between(shiftEnd, nextShiftBegin).getSeconds());
target.add(new EsperResolvedShiftSpan(shiftBegin, shiftEnd, nextShiftBegin, restSeconds));
}
private OffsetDateTime findNextActiveStartAfter(List<ActivityIntervalDto> sorted, OffsetDateTime activityTime) {
return sorted.stream()
.filter(interval -> interval.startedAt().isAfter(activityTime))
.filter(EsperActivitySemantics::isShiftActivity)
.map(ActivityIntervalDto::startedAt)
.findFirst()
.orElse(null);
}
private OffsetDateTime findLastActiveEnd(
List<ActivityIntervalDto> sorted,
OffsetDateTime shiftBegin,
OffsetDateTime nextShiftBegin
) {
return sorted.stream()
.filter(interval -> !interval.startedAt().isBefore(shiftBegin))
.filter(interval -> nextShiftBegin == null || interval.startedAt().isBefore(nextShiftBegin))
.filter(EsperActivitySemantics::isShiftActivity)
.map(ActivityIntervalDto::endedAt)
.max(OffsetDateTime::compareTo)
.orElse(null);
}
private ShiftDeviationStats calculateShiftDeviationStats(List<EsperResolvedShiftSpan> shiftSpans, ZoneOffset evaluationOffset) {
Map<String, List<EsperResolvedShiftSpan>> grouped = new LinkedHashMap<>();
for (EsperResolvedShiftSpan span : shiftSpans) {
grouped.computeIfAbsent(determineShiftKind(span, evaluationOffset), ignored -> new ArrayList<>()).add(span);
}
Map<String, DeviationSnapshot> stats = new LinkedHashMap<>();
for (Map.Entry<String, List<EsperResolvedShiftSpan>> entry : grouped.entrySet()) {
List<EsperResolvedShiftSpan> spans = entry.getValue();
if (spans.size() < 4) {
stats.put(entry.getKey(), new DeviationSnapshot(Double.NaN, Double.NaN, Double.NaN, Double.NaN, false));
continue;
}
double beginMean = spans.stream().mapToInt(span -> minutesOfDay(span.startedAt(), evaluationOffset)).average().orElse(Double.NaN);
double endMean = spans.stream().mapToInt(span -> minutesOfDay(span.endedAt(), evaluationOffset)).average().orElse(Double.NaN);
double beginDeviation = standardDeviation(spans.stream().mapToInt(span -> minutesOfDay(span.startedAt(), evaluationOffset)).toArray(), beginMean);
double endDeviation = standardDeviation(spans.stream().mapToInt(span -> minutesOfDay(span.endedAt(), evaluationOffset)).toArray(), endMean);
stats.put(entry.getKey(), new DeviationSnapshot(beginMean, endMean, beginDeviation, endDeviation, true));
}
return new ShiftDeviationStats(stats);
}
private double standardDeviation(int[] values, double mean) {
double squaredSum = 0;
for (int value : values) {
double delta = value - mean;
squaredSum += delta * delta;
}
return Math.sqrt(squaredSum / values.length);
}
private int minutesOfDay(OffsetDateTime value, ZoneOffset evaluationOffset) {
OffsetDateTime local = value.withOffsetSameInstant(evaluationOffset);
return local.getHour() * 60 + local.getMinute();
}
private String determineShiftKind(EsperResolvedShiftSpan span, ZoneOffset evaluationOffset) {
OffsetDateTime localStart = span.startedAt().withOffsetSameInstant(evaluationOffset);
OffsetDateTime localEnd = span.endedAt().withOffsetSameInstant(evaluationOffset);
if (localStart.toLocalDate().equals(localEnd.toLocalDate())) {
int startSecond = localStart.getHour() * 3600 + localStart.getMinute() * 60 + localStart.getSecond();
int endSecond = localEnd.getHour() * 3600 + localEnd.getMinute() * 60 + localEnd.getSecond();
return startSecond < 5 * 3600 || endSecond > 20 * 3600 ? "SPECIAL_HOURS" : "DAY";
}
return "OVERNIGHT";
}
private String determineUsedDataKind(List<ActivityIntervalDto> activities) {
boolean hasCard = activities.stream().anyMatch(activity -> "DRIVER_CARD".equals(activity.sourceKind()));
boolean hasVu = activities.stream().anyMatch(activity -> "VEHICLE_UNIT".equals(activity.sourceKind()));
if (hasCard && hasVu) {
return "BOTH";
}
if (hasVu) {
return "VEHICLE_UNIT";
}
return "DRIVER_CARD";
} }
private boolean isOperatingPeriodSplitRest(ActivityIntervalDto interval, Duration splitRestThreshold) { private boolean isOperatingPeriodSplitRest(ActivityIntervalDto interval, Duration splitRestThreshold) {
@ -267,15 +673,6 @@ public class EsperPocDriverCardActivityService {
&& interval.durationSeconds() > splitRestThreshold.getSeconds(); && interval.durationSeconds() > splitRestThreshold.getSeconds();
} }
private boolean canMerge(ActivityIntervalDto left, ActivityIntervalDto right, Duration tolerance) {
boolean sameDriver = Objects.equals(left.driverEntityId(), right.driverEntityId());
boolean sameActivity = Objects.equals(left.activityType(), right.activityType());
boolean sameSlot = Objects.equals(left.cardSlot(), right.cardSlot());
long gapSeconds = Duration.between(left.endedAt(), right.startedAt()).getSeconds();
boolean adjacentOrOverlapping = gapSeconds <= tolerance.getSeconds();
return sameDriver && sameActivity && sameSlot && adjacentOrOverlapping;
}
private List<ActivityIntervalDto> clipToPeriod( private List<ActivityIntervalDto> clipToPeriod(
List<ActivityIntervalDto> intervals, List<ActivityIntervalDto> intervals,
OffsetDateTime periodFrom, OffsetDateTime periodFrom,
@ -296,6 +693,18 @@ public class EsperPocDriverCardActivityService {
.toList(); .toList();
} }
private List<ActivityIntervalDto> sortedPositiveIntervals(List<ActivityIntervalDto> intervals) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
return intervals.stream()
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt)
.thenComparing(ActivityIntervalDto::endedAt)
.thenComparing(ActivityIntervalDto::activityType, Comparator.nullsLast(String::compareTo)))
.toList();
}
private DriverWorkSummaryDto summarize( private DriverWorkSummaryDto summarize(
EsperPocRequest request, EsperPocRequest request,
OffsetDateTime periodFrom, OffsetDateTime periodFrom,
@ -372,17 +781,177 @@ public class EsperPocDriverCardActivityService {
); );
} }
private List<String> notes(EsperPocRequest request) { private OperatingTimePeriodDto buildOperatingPeriod(
int sequenceNumber,
EsperPocRequest request,
OffsetDateTime spanFrom,
OffsetDateTime spanTo,
ActivityIntervalDto splitStartedAfterLongRest,
ActivityIntervalDto splitEndedByLongRest,
List<ActivityIntervalDto> allActivities
) {
List<ActivityIntervalDto> activities = allActivities.stream()
.filter(activity -> activity.startedAt().isBefore(spanTo) && activity.endedAt().isAfter(spanFrom))
.map(activity -> {
OffsetDateTime start = max(activity.startedAt(), spanFrom);
OffsetDateTime end = min(activity.endedAt(), spanTo);
if (!end.isAfter(start)) {
return null;
}
boolean clipped = !start.equals(activity.startedAt()) || !end.equals(activity.endedAt());
return activity.withTime(start, end, clipped);
})
.filter(Objects::nonNull)
.filter(activity -> activity.endedAt().isAfter(activity.startedAt()))
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt).thenComparing(ActivityIntervalDto::endedAt))
.toList();
if (activities.isEmpty()) {
return null;
}
OffsetDateTime startedAt = activities.get(0).startedAt();
OffsetDateTime endedAt = activities.get(activities.size() - 1).endedAt();
DriverWorkSummaryDto summary = summarize(request, startedAt, endedAt, activities);
ShiftDrivingEvaluationDto drivingEvaluation = evaluateSignificantDriving(
mergeActivities(
activities,
Duration.ofSeconds(request.mergeGapSeconds()),
resolveActivityMergeMode(request)
),
request.significantDrivingMinutes()
);
return new OperatingTimePeriodDto(
sequenceNumber,
startedAt,
endedAt,
Duration.between(startedAt, endedAt).getSeconds(),
splitStartedAfterLongRest,
splitEndedByLongRest,
activities,
summary,
drivingEvaluation
);
}
private OffsetDateTime firstDrivingStart(List<ActivityIntervalDto> activities) {
return activities.stream()
.filter(this::isDrivingActivity)
.map(ActivityIntervalDto::startedAt)
.min(OffsetDateTime::compareTo)
.orElse(null);
}
private OffsetDateTime lastDrivingEnd(List<ActivityIntervalDto> activities) {
return activities.stream()
.filter(this::isDrivingActivity)
.map(ActivityIntervalDto::endedAt)
.max(OffsetDateTime::compareTo)
.orElse(null);
}
private OffsetDateTime firstDrivingStart(List<ActivityIntervalDto> activities, int minMinutes) {
long threshold = Duration.ofMinutes(minMinutes).getSeconds();
return activities.stream()
.filter(this::isDrivingActivity)
.filter(activity -> activity.durationSeconds() >= threshold)
.map(ActivityIntervalDto::startedAt)
.min(OffsetDateTime::compareTo)
.orElse(null);
}
private OffsetDateTime lastDrivingEnd(List<ActivityIntervalDto> activities, int minMinutes) {
long threshold = Duration.ofMinutes(minMinutes).getSeconds();
return activities.stream()
.filter(this::isDrivingActivity)
.filter(activity -> activity.durationSeconds() >= threshold)
.map(ActivityIntervalDto::endedAt)
.max(OffsetDateTime::compareTo)
.orElse(null);
}
private boolean isDrivingActivity(ActivityIntervalDto activity) {
return EsperActivitySemantics.isKnownActivity(activity) && "DRIVE".equals(activity.activityType());
}
private int countActivities(List<ActivityIntervalDto> activities, String activityType) {
return (int) activities.stream()
.filter(EsperActivitySemantics::isKnownActivity)
.filter(activity -> activityType.equals(activity.activityType()))
.count();
}
private int countUnknownActivities(List<ActivityIntervalDto> activities) {
return (int) activities.stream()
.filter(EsperActivitySemantics::isUnknownActivity)
.count();
}
private int countWorkingActivities(List<ActivityIntervalDto> activities) {
return (int) activities.stream()
.filter(EsperActivitySemantics::isShiftActivity)
.count();
}
private List<UUID> distinctVehicleIds(List<ActivityIntervalDto> activities) {
Set<UUID> values = new LinkedHashSet<>();
for (ActivityIntervalDto activity : activities) {
if (activity.vehicleId() != null) {
values.add(activity.vehicleId());
}
}
return List.copyOf(values);
}
private List<UUID> distinctVehicleRegistrationIds(List<ActivityIntervalDto> activities) {
Set<UUID> values = new LinkedHashSet<>();
for (ActivityIntervalDto activity : activities) {
if (activity.vehicleRegistrationId() != null) {
values.add(activity.vehicleRegistrationId());
}
}
return List.copyOf(values);
}
private List<String> notes(
EsperPocRequest request,
EsperActivityMergeMode activityMergeMode,
EsperShiftResolutionMode shiftResolutionMode
) {
return List.of( return List.of(
"PoC reads only tachograph DRIVER_CARD/CARD_ACTIVITY source events from eventhub.event.", "PoC reads tachograph DRIVER_CARD/CARD_ACTIVITY events and VEHICLE_UNIT/VU_ACTIVITY events from eventhub.event.",
"Level RAW contains original imported point events; level Activities contains Esper-created intervals merged by consecutive identical activity.", "Driver-card intervals remain authoritative. VEHICLE_UNIT activity fills only uncovered time gaps in the driver-card timeline.",
"Configured activity merge mode: " + activityMergeMode + ". Configured shift resolution mode: " + shiftResolutionMode + ".",
"Resolved work shifts follow the stored-procedure split rule: BREAK_REST or unknown coverage accumulating to "
+ request.shiftEndMarkPeriodMinutes() + " minutes ends a shift.",
"Driving-time interruption evaluation is reported per operating time period and per working shift, not as a single evaluation for the complete requested period.",
"Activities are merged in the guard window first and then clipped to the requested period, so BREAK_REST across the period boundary can still split operating periods correctly.", "Activities are merged in the guard window first and then clipped to the requested period, so BREAK_REST across the period boundary can still split operating periods correctly.",
"Operating time periods are split by BREAK_REST activities longer than " + request.operatingPeriodSplitRestHours() + " hours.", "Operating time periods are split by BREAK_REST activities longer than " + request.operatingPeriodSplitRestHours() + " hours.",
"Working seconds = DRIVE + WORK. Operation seconds = DRIVE + WORK + AVAILABILITY. BREAK_REST is reported separately.", "Working seconds = DRIVE + WORK. Operation seconds = DRIVE + WORK + AVAILABILITY. BREAK_REST is reported separately."
"Departure/arrival are evaluated globally and again inside each operating time period using DRIVE intervals longer than " + request.significantDrivingMinutes() + " minutes."
); );
} }
private EsperActivityMergeMode resolveActivityMergeMode(EsperPocRequest request) {
if (request.activityMergeMode() != null) {
return request.activityMergeMode();
}
return properties == null ? EsperActivityMergeMode.JAVA : properties.getEsperPoc().getActivityMergeMode();
}
private EsperShiftResolutionMode resolveShiftResolutionMode(EsperPocRequest request) {
if (request.shiftResolutionMode() != null) {
return request.shiftResolutionMode();
}
return properties == null ? EsperShiftResolutionMode.JAVA : properties.getEsperPoc().getShiftResolutionMode();
}
private EsperDriverActivityEngine requireEsperEngine() {
if (esperEngine == null) {
throw new IllegalStateException("Esper engine is required for ESPER execution modes");
}
return esperEngine;
}
private OffsetDateTime utc(OffsetDateTime value) { private OffsetDateTime utc(OffsetDateTime value) {
return value.withOffsetSameInstant(ZoneOffset.UTC); return value.withOffsetSameInstant(ZoneOffset.UTC);
} }
@ -394,4 +963,52 @@ public class EsperPocDriverCardActivityService {
private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) { private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) {
return left.isBefore(right) ? left : right; return left.isBefore(right) ? left : right;
} }
private long elapsedMillis(long startedNanos) {
return Duration.ofNanos(System.nanoTime() - startedNanos).toMillis();
}
private record ShiftDeviationStats(Map<String, DeviationSnapshot> stats) {
ShiftDeviationSnapshot snapshot(EsperResolvedShiftSpan span, ZoneOffset evaluationOffset) {
DeviationSnapshot snapshot = stats.get(determineShiftKindStatic(span, evaluationOffset));
if (snapshot == null || !snapshot.computed()) {
return new ShiftDeviationSnapshot(true, true);
}
int beginMinutes = minutesOfDayStatic(span.startedAt(), evaluationOffset);
int endMinutes = minutesOfDayStatic(span.endedAt(), evaluationOffset);
boolean beginInRange = beginMinutes >= snapshot.beginMean() - snapshot.beginDeviation()
&& beginMinutes <= snapshot.beginMean() + snapshot.beginDeviation();
boolean endInRange = endMinutes >= snapshot.endMean() - snapshot.endDeviation()
&& endMinutes <= snapshot.endMean() + snapshot.endDeviation();
return new ShiftDeviationSnapshot(beginInRange, endInRange);
}
private static String determineShiftKindStatic(EsperResolvedShiftSpan span, ZoneOffset evaluationOffset) {
OffsetDateTime localStart = span.startedAt().withOffsetSameInstant(evaluationOffset);
OffsetDateTime localEnd = span.endedAt().withOffsetSameInstant(evaluationOffset);
if (localStart.toLocalDate().equals(localEnd.toLocalDate())) {
int startSecond = localStart.getHour() * 3600 + localStart.getMinute() * 60 + localStart.getSecond();
int endSecond = localEnd.getHour() * 3600 + localEnd.getMinute() * 60 + localEnd.getSecond();
return startSecond < 5 * 3600 || endSecond > 20 * 3600 ? "SPECIAL_HOURS" : "DAY";
}
return "OVERNIGHT";
}
private static int minutesOfDayStatic(OffsetDateTime value, ZoneOffset evaluationOffset) {
OffsetDateTime local = value.withOffsetSameInstant(evaluationOffset);
return local.getHour() * 60 + local.getMinute();
}
}
private record DeviationSnapshot(
double beginMean,
double endMean,
double beginDeviation,
double endDeviation,
boolean computed
) {
}
private record ShiftDeviationSnapshot(boolean beginInRange, boolean endInRange) {
}
} }

View File

@ -14,6 +14,9 @@ public final class EsperRawDriverActivityPoint {
private final String eventType; private final String eventType;
private final String lifecycle; private final String lifecycle;
private final String cardSlot; private final String cardSlot;
private final String cardStatus;
private final String drivingStatus;
private final String sourceKind;
public EsperRawDriverActivityPoint( public EsperRawDriverActivityPoint(
UUID eventId, UUID eventId,
@ -25,7 +28,10 @@ public final class EsperRawDriverActivityPoint {
UUID vehicleRegistrationId, UUID vehicleRegistrationId,
String eventType, String eventType,
String lifecycle, String lifecycle,
String cardSlot String cardSlot,
String cardStatus,
String drivingStatus,
String sourceKind
) { ) {
this.eventId = eventId; this.eventId = eventId;
this.occurredAt = occurredAt; this.occurredAt = occurredAt;
@ -37,6 +43,9 @@ public final class EsperRawDriverActivityPoint {
this.eventType = eventType; this.eventType = eventType;
this.lifecycle = lifecycle; this.lifecycle = lifecycle;
this.cardSlot = cardSlot; this.cardSlot = cardSlot;
this.cardStatus = cardStatus;
this.drivingStatus = drivingStatus;
this.sourceKind = sourceKind;
} }
public UUID getEventId() { public UUID getEventId() {
@ -78,4 +87,16 @@ public final class EsperRawDriverActivityPoint {
public String getCardSlot() { public String getCardSlot() {
return cardSlot; return cardSlot;
} }
public String getCardStatus() {
return cardStatus;
}
public String getDrivingStatus() {
return drivingStatus;
}
public String getSourceKind() {
return sourceKind;
}
} }

View File

@ -0,0 +1,15 @@
package at.procon.eventhub.esperpoc.service;
import java.time.Duration;
import java.time.OffsetDateTime;
record EsperResolvedShiftSpan(
OffsetDateTime startedAt,
OffsetDateTime endedAt,
OffsetDateTime nextShiftStartedAt,
long dailyRestingTimeSeconds
) {
long durationSeconds() {
return Duration.between(startedAt, endedAt).getSeconds();
}
}

View File

@ -0,0 +1,372 @@
package at.procon.eventhub.persistence;
import at.procon.eventhub.dto.DriverCardRefDto;
import at.procon.eventhub.dto.DriverRefDto;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public class DriverIdentityRepository {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public DriverIdentityRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper;
}
public UUID resolveOrCreateDriverId(
String tenantKey,
int eventSourceId,
DriverRefDto driverRef
) {
if (driverRef == null || !driverRef.hasAnyReference()) {
return null;
}
String normalizedTenantKey = normalizeRequired(tenantKey, "tenantKey");
String sourceDriverEntityId = normalizeNullable(driverRef.sourceEntityId());
DriverCardRefDto driverCard = driverRef.driverCard();
String cardNation = driverCard == null ? null : normalizeNullable(driverCard.nation());
String cardNumber = driverCard == null ? null : normalizeNullable(driverCard.number());
UUID driverId = resolveDriverId(normalizedTenantKey, eventSourceId, sourceDriverEntityId, cardNation, cardNumber);
if (driverId == null) {
Map<String, Object> payload = new LinkedHashMap<>();
put(payload, "source", "event");
put(payload, "source_driver_entity_id", sourceDriverEntityId);
put(payload, "card_nation", cardNation);
put(payload, "card_number", cardNumber);
driverId = createDriver(
normalizedTenantKey,
eventSourceId,
sourceDriverEntityId,
cardNation,
cardNumber,
null,
null,
null,
payload
);
}
touchDriver(driverId, sourceDriverEntityId, cardNation, cardNumber);
return driverId;
}
@Transactional
public int reconcileFromMasterData(String tenantKey, int eventSourceId) {
String normalizedTenantKey = normalizeRequired(tenantKey, "tenantKey");
int updates = reconcileDriversFromMasterData(normalizedTenantKey, eventSourceId);
updates += projectDriverCardsFromMasterData(normalizedTenantKey, eventSourceId);
return updates;
}
private int reconcileDriversFromMasterData(String tenantKey, int eventSourceId) {
Long count = jdbcTemplate.queryForObject(
compatibleSourcesCte() + """
, master_drivers as (
select distinct on (nullif(trim(source_entity_id), ''))
event_source_id,
nullif(trim(source_entity_id), '') as source_driver_entity_id,
nullif(trim(payload ->> 'first_names'), '') as first_names,
coalesce(
nullif(trim(payload ->> 'last_name'), ''),
nullif(trim(payload ->> 'surname'), '')
) as last_name,
cast(nullif(trim(payload ->> 'birth_date'), '') as date) as birth_date,
source_updated_at,
payload
from eventhub.source_master_entity
where tenant_key = ?
and event_source_id in (select id from compatible_sources)
and entity_type = 'DRIVER'
and nullif(trim(source_entity_id), '') is not null
and source_entity_id not like 'DRIVER_CARD:%'
order by nullif(trim(source_entity_id), ''), updated_at desc
),
updated_by_source as (
update eventhub.driver driver
set first_names = coalesce(master.first_names, driver.first_names),
last_name = coalesce(master.last_name, driver.last_name),
birth_date = coalesce(master.birth_date, driver.birth_date),
source_updated_at = master.source_updated_at,
payload = driver.payload || master.payload,
updated_at = now()
from master_drivers master
where driver.tenant_key = ?
and driver.event_source_id in (select id from compatible_sources)
and driver.source_driver_entity_id = master.source_driver_entity_id
returning driver.id
),
inserted as (
insert into eventhub.driver(
id, tenant_key, event_source_id, source_driver_entity_id,
first_names, last_name, birth_date, source_updated_at, payload, updated_at
)
select gen_random_uuid(),
?,
master.event_source_id,
master.source_driver_entity_id,
master.first_names,
master.last_name,
master.birth_date,
master.source_updated_at,
master.payload,
now()
from master_drivers master
where not exists (
select 1
from eventhub.driver existing
where existing.tenant_key = ?
and existing.event_source_id in (select id from compatible_sources)
and existing.source_driver_entity_id = master.source_driver_entity_id
)
returning id
)
select (select count(*) from updated_by_source)
+ (select count(*) from inserted)
""",
Long.class,
eventSourceId,
tenantKey,
tenantKey,
tenantKey,
tenantKey
);
return count == null ? 0 : Math.toIntExact(count);
}
private int projectDriverCardsFromMasterData(String tenantKey, int eventSourceId) {
Long count = jdbcTemplate.queryForObject(
compatibleSourcesCte() + """
, driver_card_projection as (
select distinct on (rel.to_source_entity_id)
rel.to_source_entity_id as source_driver_entity_id,
nullif(trim(card.payload ->> 'card_nation'), '') as card_nation,
nullif(trim(card.payload ->> 'card_number'), '') as card_number,
rel.source_updated_at
from eventhub.source_master_relation rel
join eventhub.source_master_entity card
on card.tenant_key = rel.tenant_key
and card.event_source_id = rel.event_source_id
and card.entity_type = 'DRIVER_CARD'
and card.source_entity_id = rel.from_source_entity_id
where rel.tenant_key = ?
and rel.event_source_id in (select id from compatible_sources)
and rel.relation_type = 'DRIVER_CARD_DRIVER'
and rel.from_entity_type = 'DRIVER_CARD'
and rel.to_entity_type = 'DRIVER'
order by rel.to_source_entity_id,
rel.valid_to desc nulls last,
rel.valid_from desc nulls last,
rel.updated_at desc
),
updated_by_source as (
update eventhub.driver driver
set card_nation = coalesce(driver.card_nation, projection.card_nation),
card_number = coalesce(driver.card_number, projection.card_number),
source_updated_at = coalesce(projection.source_updated_at, driver.source_updated_at),
updated_at = now()
from driver_card_projection projection
where driver.tenant_key = ?
and driver.event_source_id in (select id from compatible_sources)
and driver.source_driver_entity_id = projection.source_driver_entity_id
and (
(driver.card_nation is null and projection.card_nation is not null)
or (driver.card_number is null and projection.card_number is not null)
)
returning driver.id
)
select count(*)
from updated_by_source
""",
Long.class,
eventSourceId,
tenantKey,
tenantKey
);
return count == null ? 0 : Math.toIntExact(count);
}
private UUID resolveDriverId(
String tenantKey,
int eventSourceId,
String sourceDriverEntityId,
String cardNation,
String cardNumber
) {
UUID driverId = findBySourceDriverEntityId(tenantKey, eventSourceId, sourceDriverEntityId);
if (driverId == null) {
driverId = findByCard(tenantKey, eventSourceId, cardNation, cardNumber);
}
return driverId;
}
private UUID findBySourceDriverEntityId(String tenantKey, int eventSourceId, String sourceDriverEntityId) {
if (sourceDriverEntityId == null) {
return null;
}
return jdbcTemplate.query(
compatibleSourcesCte() + """
select d.id
from eventhub.driver d
where d.tenant_key = ?
and d.event_source_id in (select id from compatible_sources)
and d.source_driver_entity_id = ?
order by d.updated_at desc
limit 1
""",
rs -> rs.next() ? (UUID) rs.getObject("id") : null,
eventSourceId,
tenantKey,
sourceDriverEntityId
);
}
private UUID findByCard(String tenantKey, int eventSourceId, String cardNation, String cardNumber) {
if (cardNation == null || cardNumber == null) {
return null;
}
return jdbcTemplate.query(
compatibleSourcesCte() + """
select d.id
from eventhub.driver d
where d.tenant_key = ?
and d.event_source_id in (select id from compatible_sources)
and d.card_nation = ?
and d.card_number = ?
order by d.updated_at desc
limit 1
""",
rs -> rs.next() ? (UUID) rs.getObject("id") : null,
eventSourceId,
tenantKey,
cardNation,
cardNumber
);
}
private UUID createDriver(
String tenantKey,
int eventSourceId,
String sourceDriverEntityId,
String cardNation,
String cardNumber,
String firstNames,
String lastName,
OffsetDateTime sourceUpdatedAt,
Map<String, Object> payload
) {
UUID driverId = UUID.randomUUID();
jdbcTemplate.update(
"""
insert into eventhub.driver(
id, tenant_key, event_source_id, source_driver_entity_id,
card_nation, card_number, first_names, last_name,
source_updated_at, payload, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, now())
""",
driverId,
tenantKey,
eventSourceId,
sourceDriverEntityId,
cardNation,
cardNumber,
firstNames,
lastName,
sourceUpdatedAt,
toJson(payload)
);
return driverId;
}
private void touchDriver(
UUID driverId,
String sourceDriverEntityId,
String cardNation,
String cardNumber
) {
if (sourceDriverEntityId == null && cardNation == null && cardNumber == null) {
return;
}
jdbcTemplate.update(
"""
update eventhub.driver
set source_driver_entity_id = coalesce(source_driver_entity_id, cast(? as text)),
card_nation = coalesce(card_nation, cast(? as text)),
card_number = coalesce(card_number, cast(? as text)),
updated_at = now()
where id = ?
and (
(source_driver_entity_id is null and cast(? as text) is not null)
or (card_nation is null and cast(? as text) is not null)
or (card_number is null and cast(? as text) is not null)
)
""",
sourceDriverEntityId,
cardNation,
cardNumber,
driverId,
sourceDriverEntityId,
cardNation,
cardNumber
);
}
private String compatibleSourcesCte() {
return """
with source_context as (
select tenant_key, provider_key, source_instance_key, coalesce(tenant_provider_setting_key, '') as tenant_provider_setting_key
from eventhub.event_source
where id = ?
),
compatible_sources as (
select es.id
from eventhub.event_source es
join source_context ctx on ctx.tenant_key = es.tenant_key
and ctx.provider_key = es.provider_key
and ctx.source_instance_key = es.source_instance_key
and ctx.tenant_provider_setting_key = coalesce(es.tenant_provider_setting_key, '')
)
""";
}
private String toJson(Map<String, Object> value) {
try {
return objectMapper.writeValueAsString(value == null ? Map.of() : value);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Cannot serialize driver identity payload", e);
}
}
private void put(Map<String, Object> payload, String key, Object value) {
if (value != null) {
payload.put(key, value);
}
}
private String normalizeRequired(String value, String fieldName) {
String normalized = normalizeNullable(value);
if (normalized == null) {
throw new IllegalArgumentException(fieldName + " must not be blank");
}
return normalized;
}
private String normalizeNullable(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@ -32,6 +32,7 @@ public class EventRepository {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final EventAcquisitionRecordKeyService recordKeyService; private final EventAcquisitionRecordKeyService recordKeyService;
private final SourceMasterDataRepository sourceMasterDataRepository; private final SourceMasterDataRepository sourceMasterDataRepository;
private final DriverIdentityRepository driverIdentityRepository;
private final VehicleIdentityRepository vehicleIdentityRepository; private final VehicleIdentityRepository vehicleIdentityRepository;
public EventRepository( public EventRepository(
@ -39,12 +40,14 @@ public class EventRepository {
ObjectMapper objectMapper, ObjectMapper objectMapper,
EventAcquisitionRecordKeyService recordKeyService, EventAcquisitionRecordKeyService recordKeyService,
SourceMasterDataRepository sourceMasterDataRepository, SourceMasterDataRepository sourceMasterDataRepository,
DriverIdentityRepository driverIdentityRepository,
VehicleIdentityRepository vehicleIdentityRepository VehicleIdentityRepository vehicleIdentityRepository
) { ) {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.recordKeyService = recordKeyService; this.recordKeyService = recordKeyService;
this.sourceMasterDataRepository = sourceMasterDataRepository; this.sourceMasterDataRepository = sourceMasterDataRepository;
this.driverIdentityRepository = driverIdentityRepository;
this.vehicleIdentityRepository = vehicleIdentityRepository; this.vehicleIdentityRepository = vehicleIdentityRepository;
} }
@ -89,7 +92,7 @@ public class EventRepository {
packageId, packageId,
eventSourceId, eventSourceId,
event.externalSourceEventId(), event.externalSourceEventId(),
refs.driverEntityId(), refs.driverId(),
refs.vehicleId(), refs.vehicleId(),
refs.vehicleRegistrationId(), refs.vehicleRegistrationId(),
sourcePackageId, sourcePackageId,
@ -121,7 +124,7 @@ public class EventRepository {
data_package_id uuid not null, data_package_id uuid not null,
event_source_id integer not null, event_source_id integer not null,
external_source_event_id text not null, external_source_event_id text not null,
driver_entity_id uuid, driver_id uuid,
vehicle_id uuid, vehicle_id uuid,
vehicle_registration_id uuid, vehicle_registration_id uuid,
source_package_id text, source_package_id text,
@ -149,7 +152,7 @@ public class EventRepository {
""" """
insert into eventhub_event_import_stage( insert into eventhub_event_import_stage(
row_no, source_record_key_hash, requested_event_id, data_package_id, event_source_id, row_no, source_record_key_hash, requested_event_id, data_package_id, event_source_id,
external_source_event_id, driver_entity_id, vehicle_id, vehicle_registration_id, external_source_event_id, driver_id, vehicle_id, vehicle_registration_id,
source_package_id, source_package_entity_id, occurred_at, received_partner_at, received_hub_at, source_package_id, source_package_entity_id, occurred_at, received_partner_at, received_hub_at,
event_domain, event_type, lifecycle, odometer_m, longitude, latitude, event_domain, event_type, lifecycle, odometer_m, longitude, latitude,
payload, manual_entry, event_signature_hash payload, manual_entry, event_signature_hash
@ -165,7 +168,7 @@ public class EventRepository {
ps.setObject(4, row.packageId()); ps.setObject(4, row.packageId());
ps.setInt(5, row.eventSourceId()); ps.setInt(5, row.eventSourceId());
ps.setString(6, row.externalSourceEventId()); ps.setString(6, row.externalSourceEventId());
ps.setObject(7, row.driverEntityId()); ps.setObject(7, row.driverId());
ps.setObject(8, row.vehicleId()); ps.setObject(8, row.vehicleId());
ps.setObject(9, row.vehicleRegistrationId()); ps.setObject(9, row.vehicleRegistrationId());
ps.setString(10, row.sourcePackageId()); ps.setString(10, row.sourcePackageId());
@ -233,7 +236,7 @@ public class EventRepository {
insert into eventhub.event( insert into eventhub.event(
id, event_source_id, data_package_id, id, event_source_id, data_package_id,
external_source_event_id, external_source_event_id,
driver_entity_id, vehicle_id, vehicle_registration_id, driver_id, vehicle_id, vehicle_registration_id,
source_package_id, source_package_entity_id, source_package_id, source_package_entity_id,
occurred_at, received_partner_at, received_hub_at, occurred_at, received_partner_at, received_hub_at,
event_domain, event_type, lifecycle, event_domain, event_type, lifecycle,
@ -244,7 +247,7 @@ public class EventRepository {
select select
source_record.event_id, stage.event_source_id, stage.data_package_id, source_record.event_id, stage.event_source_id, stage.data_package_id,
stage.external_source_event_id, stage.external_source_event_id,
stage.driver_entity_id, stage.vehicle_id, stage.vehicle_registration_id, stage.driver_id, stage.vehicle_id, stage.vehicle_registration_id,
stage.source_package_id, stage.source_package_entity_id, stage.source_package_id, stage.source_package_entity_id,
source_record.event_occurred_at, stage.received_partner_at, stage.received_hub_at, source_record.event_occurred_at, stage.received_partner_at, stage.received_hub_at,
stage.event_domain, stage.event_type, stage.lifecycle, stage.event_domain, stage.event_type, stage.lifecycle,
@ -357,13 +360,13 @@ public class EventRepository {
Map<String, UUID> entityIdCache, Map<String, UUID> entityIdCache,
Map<String, List<VehicleRefCacheEntry>> vehicleRefCache Map<String, List<VehicleRefCacheEntry>> vehicleRefCache
) { ) {
UUID driverEntityId = resolveDriverEntityId(tenantKey, eventSourceId, event, entityIdCache); UUID driverId = resolveDriverId(tenantKey, eventSourceId, event, entityIdCache);
ResolvedVehicleReference vehicleRef = resolveVehicleReference(tenantKey, eventSourceId, event, vehicleRefCache); ResolvedVehicleReference vehicleRef = resolveVehicleReference(tenantKey, eventSourceId, event, vehicleRefCache);
UUID sourcePackageEntityId = resolveSourcePackageEntityId(tenantKey, eventSourceId, event, entityIdCache); UUID sourcePackageEntityId = resolveSourcePackageEntityId(tenantKey, eventSourceId, event, entityIdCache);
return new ResolvedEntityRefs(driverEntityId, vehicleRef.vehicleId(), vehicleRef.vehicleRegistrationId(), sourcePackageEntityId); return new ResolvedEntityRefs(driverId, vehicleRef.vehicleId(), vehicleRef.vehicleRegistrationId(), sourcePackageEntityId);
} }
private UUID resolveDriverEntityId( private UUID resolveDriverId(
String tenantKey, String tenantKey,
int eventSourceId, int eventSourceId,
EventHubEventDto event, EventHubEventDto event,
@ -373,32 +376,16 @@ public class EventRepository {
if (driverRef == null || !driverRef.hasAnyReference()) { if (driverRef == null || !driverRef.hasAnyReference()) {
return null; return null;
} }
String cacheKey = "DRIVER|" + driverRef.stableKey();
DriverCardRefDto card = driverRef.driverCard(); UUID cached = entityIdCache.get(cacheKey);
String cardKey = card == null || !card.hasValue() ? null : card.stableKey(); if (cached != null) {
String sourceEntityId = normalizeNullable(driverRef.sourceEntityId()); return cached;
if (sourceEntityId == null && cardKey != null) {
sourceEntityId = "DRIVER_CARD:" + cardKey;
} }
if (sourceEntityId == null) { UUID resolved = driverIdentityRepository.resolveOrCreateDriverId(tenantKey, eventSourceId, driverRef);
return null; if (resolved != null) {
entityIdCache.put(cacheKey, resolved);
} }
return resolved;
Map<String, Object> payload = new LinkedHashMap<>();
put(payload, "source_entity_id", driverRef.sourceEntityId());
put(payload, "driver_card_nation", card == null ? null : card.nation());
put(payload, "driver_card_number", card == null ? null : card.number());
return resolveEntityId(
tenantKey,
eventSourceId,
"DRIVER",
sourceEntityId,
cardKey,
sourceEntityId,
null,
payload,
entityIdCache
);
} }
private ResolvedVehicleReference resolveVehicleReference( private ResolvedVehicleReference resolveVehicleReference(
@ -599,7 +586,7 @@ public class EventRepository {
} }
private record ResolvedEntityRefs( private record ResolvedEntityRefs(
UUID driverEntityId, UUID driverId,
UUID vehicleId, UUID vehicleId,
UUID vehicleRegistrationId, UUID vehicleRegistrationId,
UUID sourcePackageEntityId UUID sourcePackageEntityId
@ -618,7 +605,7 @@ public class EventRepository {
UUID packageId, UUID packageId,
int eventSourceId, int eventSourceId,
String externalSourceEventId, String externalSourceEventId,
UUID driverEntityId, UUID driverId,
UUID vehicleId, UUID vehicleId,
UUID vehicleRegistrationId, UUID vehicleRegistrationId,
String sourcePackageId, String sourcePackageId,

View File

@ -6,6 +6,7 @@ import at.procon.eventhub.importing.masterdata.SourceMasterRelationUpsert;
import at.procon.eventhub.dto.EventSourceDto; import at.procon.eventhub.dto.EventSourceDto;
import at.procon.eventhub.persistence.EventSourceRepository; import at.procon.eventhub.persistence.EventSourceRepository;
import at.procon.eventhub.persistence.SourceMasterDataRepository; import at.procon.eventhub.persistence.SourceMasterDataRepository;
import at.procon.eventhub.persistence.DriverIdentityRepository;
import at.procon.eventhub.persistence.VehicleIdentityRepository; import at.procon.eventhub.persistence.VehicleIdentityRepository;
import at.procon.eventhub.tachograph.dto.TachographImportRequest; import at.procon.eventhub.tachograph.dto.TachographImportRequest;
import java.io.IOException; import java.io.IOException;
@ -52,6 +53,7 @@ public class TachographMasterDataRefreshService {
private final ObjectProvider<NamedParameterJdbcTemplate> tachographJdbcTemplateProvider; private final ObjectProvider<NamedParameterJdbcTemplate> tachographJdbcTemplateProvider;
private final SourceMasterDataRepository sourceMasterDataRepository; private final SourceMasterDataRepository sourceMasterDataRepository;
private final EventSourceRepository eventSourceRepository; private final EventSourceRepository eventSourceRepository;
private final DriverIdentityRepository driverIdentityRepository;
private final VehicleIdentityRepository vehicleIdentityRepository; private final VehicleIdentityRepository vehicleIdentityRepository;
private final ResourceLoader resourceLoader; private final ResourceLoader resourceLoader;
@ -59,12 +61,14 @@ public class TachographMasterDataRefreshService {
@Qualifier("tachographNamedParameterJdbcTemplate") ObjectProvider<NamedParameterJdbcTemplate> tachographJdbcTemplateProvider, @Qualifier("tachographNamedParameterJdbcTemplate") ObjectProvider<NamedParameterJdbcTemplate> tachographJdbcTemplateProvider,
SourceMasterDataRepository sourceMasterDataRepository, SourceMasterDataRepository sourceMasterDataRepository,
EventSourceRepository eventSourceRepository, EventSourceRepository eventSourceRepository,
DriverIdentityRepository driverIdentityRepository,
VehicleIdentityRepository vehicleIdentityRepository, VehicleIdentityRepository vehicleIdentityRepository,
ResourceLoader resourceLoader ResourceLoader resourceLoader
) { ) {
this.tachographJdbcTemplateProvider = tachographJdbcTemplateProvider; this.tachographJdbcTemplateProvider = tachographJdbcTemplateProvider;
this.sourceMasterDataRepository = sourceMasterDataRepository; this.sourceMasterDataRepository = sourceMasterDataRepository;
this.eventSourceRepository = eventSourceRepository; this.eventSourceRepository = eventSourceRepository;
this.driverIdentityRepository = driverIdentityRepository;
this.vehicleIdentityRepository = vehicleIdentityRepository; this.vehicleIdentityRepository = vehicleIdentityRepository;
this.resourceLoader = resourceLoader; this.resourceLoader = resourceLoader;
} }
@ -98,13 +102,17 @@ public class TachographMasterDataRefreshService {
int relationCount = streamRelations(tachographJdbcTemplate, tenantKey, eventSourceId, RELATIONS_SQL_RESOURCE, loadSql(RELATIONS_SQL_RESOURCE)); int relationCount = streamRelations(tachographJdbcTemplate, tenantKey, eventSourceId, RELATIONS_SQL_RESOURCE, loadSql(RELATIONS_SQL_RESOURCE));
log.info("Reconciling tachograph driver identities from source master data tenant={} source={}",
tenantKey, masterDataSource.stableKey());
int reconciledDrivers = driverIdentityRepository.reconcileFromMasterData(tenantKey, eventSourceId);
log.info("Reconciling tachograph vehicle identities from source master data tenant={} source={}", log.info("Reconciling tachograph vehicle identities from source master data tenant={} source={}",
tenantKey, masterDataSource.stableKey()); tenantKey, masterDataSource.stableKey());
int reconciledVehicles = vehicleIdentityRepository.reconcileFromMasterData(tenantKey, eventSourceId); int reconciledVehicles = vehicleIdentityRepository.reconcileFromMasterData(tenantKey, eventSourceId);
MasterDataRefreshResult result = new MasterDataRefreshResult(entities, relationCount); MasterDataRefreshResult result = new MasterDataRefreshResult(entities, relationCount);
log.info("Refreshed tachograph source master data tenant={} source={} entities={} relations={} reconciledVehicles={}", log.info("Refreshed tachograph source master data tenant={} source={} entities={} relations={} reconciledDrivers={} reconciledVehicles={}",
tenantKey, masterDataSource.stableKey(), result.entitiesUpserted(), result.relationsUpserted(), reconciledVehicles); tenantKey, masterDataSource.stableKey(), result.entitiesUpserted(), result.relationsUpserted(), reconciledDrivers, reconciledVehicles);
return result; return result;
} }

View File

@ -63,7 +63,7 @@ eventhub:
# Enables the scheduler that regularly triggers configured tachograph import plans. # Enables the scheduler that regularly triggers configured tachograph import plans.
# Default is safe: no scheduled import starts unless explicitly enabled. # Default is safe: no scheduled import starts unless explicitly enabled.
scheduler-enabled: false scheduler-enabled: true
scheduler-poll-interval-ms: 3600000 scheduler-poll-interval-ms: 3600000
# PLAN_ONLY creates import_run + planned extraction packages. # PLAN_ONLY creates import_run + planned extraction packages.
@ -77,10 +77,10 @@ eventhub:
# Example plan. Keep disabled until the tachograph datasource/extractor is wired. # Example plan. Keep disabled until the tachograph datasource/extractor is wired.
import-plans: import-plans:
- plan-key: kralowetz-tachograph-org-147 - plan-key: tachograph-org-14708
enabled: false enabled: true
cron: "0 15 * * * *" # hourly at minute 15 cron: "0 15 * * * *" # hourly at minute 15
tenant-key: tenant-key: Procon
event-source: event-source:
provider-key: TACHOGRAPH provider-key: TACHOGRAPH
source-kind: MIXED source-kind: MIXED
@ -89,16 +89,16 @@ eventhub:
tenant-provider-setting-key: ByteBar-DriverSettlement tenant-provider-setting-key: ByteBar-DriverSettlement
source-group: source-group:
type: ORGANISATION type: ORGANISATION
source-entity-id: "147" source-entity-id: "14708"
code: "147" code: "14708"
name: Kralowetz root organisation name: Zeller root organisation
import-scope: import-scope:
type: SOURCE_ORGANISATION_SUBTREE type: SOURCE_ORGANISATION_SUBTREE
root-source-organisation: root-source-organisation:
type: ORGANISATION type: ORGANISATION
source-entity-id: "147" source-entity-id: "14708"
code: "147" code: "14708"
name: Kralowetz root organisation name: Zeller root organisation
include-children: true include-children: true
occurred-from: null occurred-from: null
occurred-to: null occurred-to: null
@ -115,11 +115,15 @@ eventhub:
scheduled-mode: INCREMENTAL_UPDATE scheduled-mode: INCREMENTAL_UPDATE
initial-strategy: OCCURRED_AT_WINDOW_WITH_OVERLAP initial-strategy: OCCURRED_AT_WINDOW_WITH_OVERLAP
scheduled-strategy: SOURCE_PACKAGE_WATERMARK scheduled-strategy: SOURCE_PACKAGE_WATERMARK
refresh-master-data-first: false refresh-master-data-first: true
initial-occurred-from: "2026-01-21T00:00:00+01:00" initial-occurred-from: "2026-01-21T00:00:00+01:00"
initial-occurred-to: "2026-01-31T00:00:00+01:00" initial-occurred-to:
run-initial-on-startup: true run-initial-on-startup: true
esper-poc:
activity-merge-mode: JAVA
shift-resolution-mode: JAVA
yellow-fox: yellow-fox:
default-chunk-days: 1 default-chunk-days: 1
occurred-at-overlap: 2h occurred-at-overlap: 2h

View File

@ -0,0 +1,137 @@
create table if not exists eventhub.driver (
id uuid primary key,
tenant_key text not null,
event_source_id integer not null references eventhub.event_source(id),
source_driver_entity_id text,
card_nation text,
card_number text,
first_names text,
last_name text,
birth_date date,
source_updated_at timestamptz,
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint chk_driver_card_nation_when_number
check (card_number is null or card_nation is not null)
);
insert into eventhub.driver(
id, tenant_key, event_source_id, source_driver_entity_id,
card_nation, card_number, first_names, last_name, birth_date,
source_updated_at, payload, created_at, updated_at
)
select gen_random_uuid(),
sme.tenant_key,
sme.event_source_id,
case
when sme.source_entity_id like 'DRIVER_CARD:%' then null
else sme.source_entity_id
end as source_driver_entity_id,
coalesce(
nullif(trim(sme.payload ->> 'card_nation'), ''),
nullif(trim(sme.payload ->> 'driver_card_nation'), ''),
nullif(split_part(case when sme.source_entity_id like 'DRIVER_CARD:%' then substring(sme.source_entity_id from length('DRIVER_CARD:') + 1) else null end, ':', 1), '')
) as card_nation,
coalesce(
nullif(trim(sme.payload ->> 'card_number'), ''),
nullif(trim(sme.payload ->> 'driver_card_number'), ''),
nullif(substring(case when sme.source_entity_id like 'DRIVER_CARD:%' then substring(sme.source_entity_id from length('DRIVER_CARD:') + 1) else null end from position(':' in case when sme.source_entity_id like 'DRIVER_CARD:%' then substring(sme.source_entity_id from length('DRIVER_CARD:') + 1) else '' end) + 1), '')
) as card_number,
coalesce(
nullif(trim(sme.payload ->> 'first_names'), ''),
nullif(trim(sme.payload ->> 'firstnames'), '')
) as first_names,
coalesce(
nullif(trim(sme.payload ->> 'last_name'), ''),
nullif(trim(sme.payload ->> 'surname'), '')
) as last_name,
cast(nullif(trim(sme.payload ->> 'birth_date'), '') as date) as birth_date,
sme.source_updated_at,
sme.payload,
sme.created_at,
sme.updated_at
from eventhub.source_master_entity sme
where sme.entity_type = 'DRIVER'
and not exists (
select 1
from eventhub.driver existing
where existing.tenant_key = sme.tenant_key
and existing.event_source_id = sme.event_source_id
and existing.source_driver_entity_id is not distinct from case
when sme.source_entity_id like 'DRIVER_CARD:%' then null
else sme.source_entity_id
end
and existing.card_nation is not distinct from coalesce(
nullif(trim(sme.payload ->> 'card_nation'), ''),
nullif(trim(sme.payload ->> 'driver_card_nation'), ''),
nullif(split_part(case when sme.source_entity_id like 'DRIVER_CARD:%' then substring(sme.source_entity_id from length('DRIVER_CARD:') + 1) else null end, ':', 1), '')
)
and existing.card_number is not distinct from coalesce(
nullif(trim(sme.payload ->> 'card_number'), ''),
nullif(trim(sme.payload ->> 'driver_card_number'), ''),
nullif(substring(case when sme.source_entity_id like 'DRIVER_CARD:%' then substring(sme.source_entity_id from length('DRIVER_CARD:') + 1) else null end from position(':' in case when sme.source_entity_id like 'DRIVER_CARD:%' then substring(sme.source_entity_id from length('DRIVER_CARD:') + 1) else '' end) + 1), '')
)
);
alter table eventhub.event
add column if not exists driver_id uuid references eventhub.driver(id);
with source_driver_map as (
select distinct on (sme.id)
sme.id as source_master_entity_id,
driver.id as driver_id
from eventhub.source_master_entity sme
join eventhub.driver driver
on driver.tenant_key = sme.tenant_key
and driver.event_source_id = sme.event_source_id
and (
(
sme.source_entity_id not like 'DRIVER_CARD:%'
and driver.source_driver_entity_id = sme.source_entity_id
) or (
sme.source_entity_id like 'DRIVER_CARD:%'
and driver.card_nation is not distinct from coalesce(
nullif(trim(sme.payload ->> 'card_nation'), ''),
nullif(trim(sme.payload ->> 'driver_card_nation'), ''),
nullif(split_part(substring(sme.source_entity_id from length('DRIVER_CARD:') + 1), ':', 1), '')
)
and driver.card_number is not distinct from coalesce(
nullif(trim(sme.payload ->> 'card_number'), ''),
nullif(trim(sme.payload ->> 'driver_card_number'), ''),
nullif(substring(substring(sme.source_entity_id from length('DRIVER_CARD:') + 1) from position(':' in substring(sme.source_entity_id from length('DRIVER_CARD:') + 1)) + 1), '')
)
)
)
where sme.entity_type = 'DRIVER'
order by sme.id, driver.updated_at desc
)
update eventhub.event event
set driver_id = map.driver_id
from source_driver_map map
where event.driver_entity_id = map.source_master_entity_id
and event.driver_id is null;
create index if not exists idx_driver_source_entity
on eventhub.driver(tenant_key, event_source_id, source_driver_entity_id)
where source_driver_entity_id is not null;
create index if not exists idx_driver_card
on eventhub.driver(tenant_key, event_source_id, card_nation, card_number)
where card_number is not null;
create index if not exists idx_event_driver_time
on eventhub.event(driver_id, occurred_at desc)
where driver_id is not null;
alter table eventhub.event
drop constraint if exists chk_event_driver_or_vehicle_ref;
alter table eventhub.event
add constraint chk_event_driver_or_vehicle_ref
check (
driver_id is not null
or driver_entity_id is not null
or vehicle_id is not null
or vehicle_registration_id is not null
);

View File

@ -0,0 +1,239 @@
/*
* Repairs and normalizes tachograph driver aggregates after introducing eventhub.driver.
*
* What it does:
* 1. Ensures tachograph DRIVER master-data payload carries last_name while keeping source_master_entity.display_name unchanged.
* 2. Upserts eventhub.driver rows from MASTER_DATA DRIVER entities.
* 3. Projects card nation/number onto eventhub.driver from DRIVER_CARD_DRIVER relations.
* 4. Remaps event.driver_id from provisional card-only drivers to proper source-driver aggregates when possible.
* 5. Deletes now-unreferenced provisional tachograph driver rows with no source_driver_entity_id.
*
* Assumptions:
* - Tachograph master-data source is provider_key=TACHOGRAPH, source_kind=MASTER_DATA, source_key=TACHOGRAPH_MASTER_DATA.
* - eventhub.driver and event.driver_id already exist.
*/
-- 1) Keep display_name, but ensure DRIVER payload has last_name.
with master_sources as (
select es.id, es.tenant_key
from eventhub.event_source es
where es.provider_key = 'TACHOGRAPH'
and es.source_kind = 'MASTER_DATA'
and es.source_key = 'TACHOGRAPH_MASTER_DATA'
),
updated_master_payload as (
update eventhub.source_master_entity sme
set payload = jsonb_strip_nulls(
sme.payload
|| jsonb_build_object(
'first_names', coalesce(sme.payload ->> 'first_names', sme.payload ->> 'firstnames'),
'last_name', coalesce(sme.payload ->> 'last_name', sme.payload ->> 'surname')
)
),
updated_at = now()
from master_sources ms
where sme.tenant_key = ms.tenant_key
and sme.event_source_id = ms.id
and sme.entity_type = 'DRIVER'
returning sme.id
)
select count(*) as updated_master_payload
from updated_master_payload;
-- 2) Upsert driver aggregates from tachograph master data.
with master_sources as (
select es.id,
es.tenant_key,
es.source_instance_key,
coalesce(es.tenant_provider_setting_key, '') as tenant_provider_setting_key
from eventhub.event_source es
where es.provider_key = 'TACHOGRAPH'
and es.source_kind = 'MASTER_DATA'
and es.source_key = 'TACHOGRAPH_MASTER_DATA'
),
master_drivers as (
select ms.id as master_event_source_id,
ms.tenant_key,
ms.source_instance_key,
ms.tenant_provider_setting_key,
d.source_entity_id as source_driver_entity_id,
coalesce(nullif(trim(d.payload ->> 'first_names'), ''), nullif(trim(d.payload ->> 'firstnames'), '')) as first_names,
coalesce(nullif(trim(d.payload ->> 'last_name'), ''), nullif(trim(d.payload ->> 'surname'), '')) as last_name,
cast(nullif(trim(d.payload ->> 'birth_date'), '') as date) as birth_date,
d.source_updated_at,
d.payload
from master_sources ms
join eventhub.source_master_entity d
on d.tenant_key = ms.tenant_key
and d.event_source_id = ms.id
and d.entity_type = 'DRIVER'
and d.source_entity_id not like 'DRIVER_CARD:%'
),
compatible_targets as (
select md.*,
es.id as target_event_source_id
from master_drivers md
join eventhub.event_source es
on es.tenant_key = md.tenant_key
and es.provider_key = 'TACHOGRAPH'
and es.source_instance_key = md.source_instance_key
and coalesce(es.tenant_provider_setting_key, '') = md.tenant_provider_setting_key
),
updated_drivers as (
update eventhub.driver driver
set first_names = coalesce(ct.first_names, driver.first_names),
last_name = coalesce(ct.last_name, driver.last_name),
birth_date = coalesce(ct.birth_date, driver.birth_date),
source_updated_at = ct.source_updated_at,
payload = driver.payload || ct.payload,
updated_at = now()
from compatible_targets ct
where driver.tenant_key = ct.tenant_key
and driver.event_source_id = ct.target_event_source_id
and driver.source_driver_entity_id = ct.source_driver_entity_id
returning driver.id
),
inserted_drivers as (
insert into eventhub.driver(
id, tenant_key, event_source_id, source_driver_entity_id,
first_names, last_name, birth_date, source_updated_at, payload, updated_at
)
select gen_random_uuid(),
ct.tenant_key,
ct.target_event_source_id,
ct.source_driver_entity_id,
ct.first_names,
ct.last_name,
ct.birth_date,
ct.source_updated_at,
ct.payload,
now()
from compatible_targets ct
where not exists (
select 1
from eventhub.driver existing
where existing.tenant_key = ct.tenant_key
and existing.event_source_id = ct.target_event_source_id
and existing.source_driver_entity_id = ct.source_driver_entity_id
)
returning id
)
select (select count(*) from updated_drivers) as updated_drivers,
(select count(*) from inserted_drivers) as inserted_drivers;
-- 3) Project driver-card identifiers from master-data relations.
with master_sources as (
select es.id,
es.tenant_key,
es.source_instance_key,
coalesce(es.tenant_provider_setting_key, '') as tenant_provider_setting_key
from eventhub.event_source es
where es.provider_key = 'TACHOGRAPH'
and es.source_kind = 'MASTER_DATA'
and es.source_key = 'TACHOGRAPH_MASTER_DATA'
),
card_projection as (
select distinct on (ms.tenant_key, ms.source_instance_key, ms.tenant_provider_setting_key, rel.to_source_entity_id)
ms.tenant_key,
ms.source_instance_key,
ms.tenant_provider_setting_key,
rel.to_source_entity_id as source_driver_entity_id,
nullif(trim(card.payload ->> 'card_nation'), '') as card_nation,
nullif(trim(card.payload ->> 'card_number'), '') as card_number,
rel.source_updated_at
from master_sources ms
join eventhub.source_master_relation rel
on rel.tenant_key = ms.tenant_key
and rel.event_source_id = ms.id
and rel.relation_type = 'DRIVER_CARD_DRIVER'
and rel.from_entity_type = 'DRIVER_CARD'
and rel.to_entity_type = 'DRIVER'
join eventhub.source_master_entity card
on card.tenant_key = ms.tenant_key
and card.event_source_id = ms.id
and card.entity_type = 'DRIVER_CARD'
and card.source_entity_id = rel.from_source_entity_id
order by ms.tenant_key,
ms.source_instance_key,
ms.tenant_provider_setting_key,
rel.to_source_entity_id,
rel.valid_to desc nulls last,
rel.valid_from desc nulls last,
rel.updated_at desc
),
updated_driver_cards as (
update eventhub.driver driver
set card_nation = coalesce(driver.card_nation, projection.card_nation),
card_number = coalesce(driver.card_number, projection.card_number),
source_updated_at = coalesce(projection.source_updated_at, driver.source_updated_at),
updated_at = now()
from card_projection projection
join eventhub.event_source es
on es.id = driver.event_source_id
where driver.tenant_key = projection.tenant_key
and es.provider_key = 'TACHOGRAPH'
and es.source_instance_key = projection.source_instance_key
and coalesce(es.tenant_provider_setting_key, '') = projection.tenant_provider_setting_key
and driver.source_driver_entity_id = projection.source_driver_entity_id
and (
(driver.card_nation is null and projection.card_nation is not null)
or (driver.card_number is null and projection.card_number is not null)
)
returning driver.id
)
select count(*) as updated_driver_cards
from updated_driver_cards;
-- 4) Remap events from provisional card-only drivers to proper source-driver aggregates.
with provisional_to_real as (
select provisional.id as provisional_driver_id,
real.id as real_driver_id
from eventhub.driver provisional
join eventhub.event_source provisional_source
on provisional_source.id = provisional.event_source_id
and provisional_source.provider_key = 'TACHOGRAPH'
join eventhub.driver real
on real.tenant_key = provisional.tenant_key
and real.source_driver_entity_id is not null
and real.card_nation = provisional.card_nation
and real.card_number = provisional.card_number
join eventhub.event_source real_source
on real_source.id = real.event_source_id
and real_source.provider_key = provisional_source.provider_key
and real_source.tenant_key = provisional_source.tenant_key
and real_source.source_instance_key = provisional_source.source_instance_key
and coalesce(real_source.tenant_provider_setting_key, '') = coalesce(provisional_source.tenant_provider_setting_key, '')
where provisional.source_driver_entity_id is null
and provisional.card_nation is not null
and provisional.card_number is not null
and provisional.id <> real.id
),
updated_events as (
update eventhub.event e
set driver_id = map.real_driver_id
from provisional_to_real map
where e.driver_id = map.provisional_driver_id
and e.driver_id <> map.real_driver_id
returning e.id
)
select count(*) as remapped_events
from updated_events;
-- 5) Delete now-unreferenced provisional tachograph driver rows.
with deleted_drivers as (
delete from eventhub.driver driver
using eventhub.event_source es
where es.id = driver.event_source_id
and es.provider_key = 'TACHOGRAPH'
and driver.source_driver_entity_id is null
and driver.card_nation is not null
and driver.card_number is not null
and not exists (
select 1
from eventhub.event e
where e.driver_id = driver.id
)
returning driver.id
)
select count(*) as deleted_provisional_drivers
from deleted_drivers;

View File

@ -9,6 +9,7 @@ select
d.LastUpdate as source_updated_at, d.LastUpdate as source_updated_at,
d.ID as driver_id, d.ID as driver_id,
d.Surname as surname, d.Surname as surname,
d.Surname as last_name,
d.Firstnames as first_names, d.Firstnames as first_names,
d.Birthdate as birth_date, d.Birthdate as birth_date,
d.BirthPlace as birth_place, d.BirthPlace as birth_place,

View File

@ -45,6 +45,8 @@ class EsperDriverActivityEngineTest {
OffsetDateTime.parse(occurredAt), OffsetDateTime.parse(occurredAt),
rowId, rowId,
"TACHOGRAPH:CARD_ACTIVITY:" + rowId + ":" + lifecycle, "TACHOGRAPH:CARD_ACTIVITY:" + rowId + ":" + lifecycle,
"DRIVER_CARD",
"CARD_ACTIVITY",
driverId, driverId,
vehicleId, vehicleId,
null, null,

View File

@ -3,7 +3,10 @@ package at.procon.eventhub.esperpoc.service;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.esperpoc.dto.ActivityIntervalDto; import at.procon.eventhub.esperpoc.dto.ActivityIntervalDto;
import at.procon.eventhub.esperpoc.dto.EsperActivityMergeMode;
import at.procon.eventhub.esperpoc.dto.EsperPocRequest; import at.procon.eventhub.esperpoc.dto.EsperPocRequest;
import at.procon.eventhub.esperpoc.dto.EsperShiftResolutionMode;
import java.time.Duration;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -11,7 +14,8 @@ import org.junit.jupiter.api.Test;
class EsperPocDriverCardActivityServiceTest { class EsperPocDriverCardActivityServiceTest {
private final EsperPocDriverCardActivityService service = new EsperPocDriverCardActivityService(null, null); private final EsperDriverActivityEngine engine = new EsperDriverActivityEngine();
private final EsperPocDriverCardActivityService service = new EsperPocDriverCardActivityService(null, engine);
@Test @Test
void mergesIdenticalActivitiesAcrossUtcMidnight() { void mergesIdenticalActivitiesAcrossUtcMidnight() {
@ -22,6 +26,9 @@ class EsperPocDriverCardActivityServiceTest {
null, null,
"BREAK_REST", "BREAK_REST",
"DRIVER", "DRIVER",
"INSERTED",
"SINGLE",
"DRIVER_CARD",
OffsetDateTime.parse("2026-04-30T23:50:00Z"), OffsetDateTime.parse("2026-04-30T23:50:00Z"),
OffsetDateTime.parse("2026-05-01T00:00:00Z"), OffsetDateTime.parse("2026-05-01T00:00:00Z"),
"1" "1"
@ -32,6 +39,9 @@ class EsperPocDriverCardActivityServiceTest {
null, null,
"BREAK_REST", "BREAK_REST",
"DRIVER", "DRIVER",
"INSERTED",
"SINGLE",
"DRIVER_CARD",
OffsetDateTime.parse("2026-05-01T00:00:00Z"), OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-01T00:20:00Z"), OffsetDateTime.parse("2026-05-01T00:20:00Z"),
"2" "2"
@ -39,7 +49,7 @@ class EsperPocDriverCardActivityServiceTest {
var merged = service.mergeConsecutiveIdenticalActivities( var merged = service.mergeConsecutiveIdenticalActivities(
List.of(beforeMidnight, afterMidnight), List.of(beforeMidnight, afterMidnight),
java.time.Duration.ZERO Duration.ZERO
); );
assertThat(merged).hasSize(1); assertThat(merged).hasSize(1);
@ -52,25 +62,16 @@ class EsperPocDriverCardActivityServiceTest {
@Test @Test
void splitsOperatingPeriodsByBreakRestLongerThanConfiguredHoursAndEvaluatesDepartureArrivalPerPeriod() { void splitsOperatingPeriodsByBreakRestLongerThanConfiguredHoursAndEvaluatesDepartureArrivalPerPeriod() {
UUID driverId = UUID.randomUUID(); UUID driverId = UUID.randomUUID();
EsperPocRequest request = new EsperPocRequest( EsperPocRequest request = request(driverId, null, null);
"default",
driverId,
OffsetDateTime.parse("2026-04-01T00:00:00Z"),
OffsetDateTime.parse("2026-04-03T00:00:00Z"),
24,
3,
60,
7
);
List<ActivityIntervalDto> activities = List.of( List<ActivityIntervalDto> activities = List.of(
activity(driverId, "DRIVE", "2026-04-01T06:00:00Z", "2026-04-01T08:00:00Z", "d1"), activity(driverId, "DRIVE", "2026-04-01T06:00:00Z", "2026-04-01T08:00:00Z", "d1", "DRIVER_CARD"),
activity(driverId, "WORK", "2026-04-01T08:00:00Z", "2026-04-01T09:00:00Z", "w1"), activity(driverId, "WORK", "2026-04-01T08:00:00Z", "2026-04-01T09:00:00Z", "w1", "DRIVER_CARD"),
activity(driverId, "DRIVE", "2026-04-01T09:30:00Z", "2026-04-01T11:00:00Z", "d2"), activity(driverId, "DRIVE", "2026-04-01T09:30:00Z", "2026-04-01T11:00:00Z", "d2", "DRIVER_CARD"),
activity(driverId, "BREAK_REST", "2026-04-01T20:00:00Z", "2026-04-02T04:30:00Z", "r1"), activity(driverId, "BREAK_REST", "2026-04-01T20:00:00Z", "2026-04-02T04:30:00Z", "r1", "DRIVER_CARD"),
activity(driverId, "DRIVE", "2026-04-02T05:00:00Z", "2026-04-02T07:00:00Z", "d3"), activity(driverId, "DRIVE", "2026-04-02T05:00:00Z", "2026-04-02T07:00:00Z", "d3", "DRIVER_CARD"),
activity(driverId, "BREAK_REST", "2026-04-02T10:00:00Z", "2026-04-02T10:30:00Z", "r2"), activity(driverId, "BREAK_REST", "2026-04-02T10:00:00Z", "2026-04-02T10:30:00Z", "r2", "DRIVER_CARD"),
activity(driverId, "DRIVE", "2026-04-02T10:30:00Z", "2026-04-02T12:00:00Z", "d4") activity(driverId, "DRIVE", "2026-04-02T10:30:00Z", "2026-04-02T12:00:00Z", "d4", "DRIVER_CARD")
); );
var periods = service.buildOperatingTimePeriods( var periods = service.buildOperatingTimePeriods(
@ -99,13 +100,135 @@ class EsperPocDriverCardActivityServiceTest {
assertThat(periods.get(1).workingOperationTimes().breakRestSeconds()).isEqualTo(30 * 60L); assertThat(periods.get(1).workingOperationTimes().breakRestSeconds()).isEqualTo(30 * 60L);
} }
private ActivityIntervalDto activity(UUID driverId, String activity, String from, String to, String sourceRowId) { @Test
void usesVuIntervalsOnlyForUncoveredDriverCardGaps() {
UUID driverId = UUID.randomUUID();
List<ActivityIntervalDto> resolved = service.resolveVuFillGaps(
List.of(
activity(driverId, "DRIVE", "2026-04-01T08:00:00Z", "2026-04-01T10:00:00Z", "c1", "DRIVER_CARD")
),
List.of(
activity(driverId, "DRIVE", "2026-04-01T09:00:00Z", "2026-04-01T11:00:00Z", "v1", "VEHICLE_UNIT")
)
);
assertThat(resolved).hasSize(2);
assertThat(resolved.get(0).sourceKind()).isEqualTo("DRIVER_CARD");
assertThat(resolved.get(1).sourceKind()).isEqualTo("VEHICLE_UNIT");
assertThat(resolved.get(1).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T10:00:00Z"));
assertThat(resolved.get(1).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T11:00:00Z"));
}
@Test
void resolvesTwoWorkingShiftsAcrossLongRestGap() {
UUID driverId = UUID.randomUUID();
EsperPocRequest request = request(driverId, null, null);
var shifts = service.buildResolvedWorkShifts(
request,
request.occurredFrom(),
request.occurredTo(),
List.of(
activity(driverId, "DRIVE", "2026-04-01T06:00:00Z", "2026-04-01T10:00:00Z", "d1", "DRIVER_CARD"),
activity(driverId, "BREAK_REST", "2026-04-01T10:00:00Z", "2026-04-01T17:30:00Z", "r1", "DRIVER_CARD"),
activity(driverId, "WORK", "2026-04-01T17:30:00Z", "2026-04-01T19:00:00Z", "w1", "VEHICLE_UNIT")
)
);
assertThat(shifts).hasSize(2);
assertThat(shifts.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T06:00:00Z"));
assertThat(shifts.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T10:00:00Z"));
assertThat(shifts.get(0).dailyRestingTimeSeconds()).isEqualTo(7L * 60L * 60L + 30L * 60L);
assertThat(shifts.get(1).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T17:30:00Z"));
assertThat(shifts.get(1).usedDataKind()).isEqualTo("VEHICLE_UNIT");
}
@Test
void esperMergeModeMatchesJavaMergeMode() {
UUID driverId = UUID.randomUUID();
List<ActivityIntervalDto> activities = List.of(
activity(driverId, "BREAK_REST", "2026-04-01T08:00:00Z", "2026-04-01T09:00:00Z", "r1", "DRIVER_CARD"),
activity(driverId, "BREAK_REST", "2026-04-01T09:00:00Z", "2026-04-01T10:00:00Z", "r2", "DRIVER_CARD"),
activity(driverId, "WORK", "2026-04-01T10:05:00Z", "2026-04-01T11:00:00Z", "w1", "DRIVER_CARD")
);
List<ActivityIntervalDto> javaMerged = service.mergeConsecutiveIdenticalActivities(
activities,
Duration.ofSeconds(60)
);
List<ActivityIntervalDto> esperMerged = engine.mergeConsecutiveIdenticalActivities(
activities,
Duration.ofSeconds(60)
);
assertThat(esperMerged).usingRecursiveComparison().isEqualTo(javaMerged);
}
@Test
void esperShiftResolutionModeMatchesJavaShiftResolutionMode() {
UUID driverId = UUID.randomUUID();
EsperPocRequest javaRequest = request(driverId, EsperActivityMergeMode.JAVA, EsperShiftResolutionMode.JAVA);
EsperPocRequest esperRequest = request(driverId, EsperActivityMergeMode.JAVA, EsperShiftResolutionMode.ESPER);
List<ActivityIntervalDto> intervals = List.of(
activity(driverId, "DRIVE", "2026-04-01T06:00:00Z", "2026-04-01T10:00:00Z", "d1", "DRIVER_CARD"),
activity(driverId, "BREAK_REST", "2026-04-01T10:00:00Z", "2026-04-01T17:30:00Z", "r1", "DRIVER_CARD"),
activity(driverId, "WORK", "2026-04-01T17:30:00Z", "2026-04-01T19:00:00Z", "w1", "VEHICLE_UNIT")
);
var javaShifts = service.buildResolvedWorkShifts(
javaRequest,
javaRequest.occurredFrom(),
javaRequest.occurredTo(),
intervals
);
var esperShifts = service.buildResolvedWorkShifts(
esperRequest,
esperRequest.occurredFrom(),
esperRequest.occurredTo(),
intervals
);
assertThat(esperShifts).usingRecursiveComparison().isEqualTo(javaShifts);
}
private EsperPocRequest request(
UUID driverId,
EsperActivityMergeMode activityMergeMode,
EsperShiftResolutionMode shiftResolutionMode
) {
return new EsperPocRequest(
"default",
driverId,
OffsetDateTime.parse("2026-04-01T00:00:00Z"),
OffsetDateTime.parse("2026-04-03T00:00:00Z"),
24,
3,
60,
7,
420,
5,
activityMergeMode,
shiftResolutionMode
);
}
private ActivityIntervalDto activity(
UUID driverId,
String activity,
String from,
String to,
String sourceRowId,
String sourceKind
) {
return ActivityIntervalDto.raw( return ActivityIntervalDto.raw(
driverId, driverId,
null, null,
null, null,
activity, activity,
"DRIVER", "DRIVER",
"INSERTED",
"KNOWN",
sourceKind,
OffsetDateTime.parse(from), OffsetDateTime.parse(from),
OffsetDateTime.parse(to), OffsetDateTime.parse(to),
sourceRowId sourceRowId