Add runtime response shaping and vehicle usage reconciliation

This commit is contained in:
trifonovt 2026-06-11 16:10:43 +02:00
parent 46a89ea5b5
commit 829dc2e06a
17 changed files with 1278 additions and 55 deletions

View File

@ -1,45 +1,109 @@
# EventHub runtime event-mixing refactor # Patch: Vehicle Usage Interval Reconciliation
This patch refactors the previous targeted card/VU duplicate handling into a first-class runtime event-mixing subsystem. This patch extends the already introduced runtime event-mixing architecture with an interval-level reconciliation step for tachograph vehicle-usage evidence.
## New architecture components ## New module
- `RuntimeEventMixingModule` Added runtime module:
- `RuntimeEventMixingService`
- `RuntimeEventDescriptor`
- `RuntimeEventDescriptorFactory`
- `RuntimeEventSourceProfile`
- `RuntimeEventMixingRule`
- `RuntimeEventMixingRuleRegistry`
- `RuntimeEventMixingDecisionDto`
- `RuntimeMixedEventBundle`
- `RuntimeResolvedEvent`
- `RuntimeResolvedEventRole`
- `RuntimeEventMixingChannel`
## Current configured rules ```text
vehicle-usage-reconciliation
```
The rule registry currently applies these tachograph same-source rules: It runs after:
1. `tachograph.activity.card-vu.same-event-key` ```text
2. `tachograph.activity.card-vu.compatible-activity-key` event-to-vehicle-usage-intervals
3. `tachograph.support.card-vu.same-event-key` ```
4. `tachograph.support.card-vu.compatible-support-key`
The activity rules collapse duplicate `CARD_ACTIVITY`/`VU_ACTIVITY` points before activity intervalization. and before:
The support rules collapse duplicate card/VU support evidence for: ```text
vehicle-usage-merge
```
- `CARD_POSITION` / `VU_POSITION` ## Main behavior
- `CARD_PLACE` / `VU_PLACE`
- `CARD_BORDER_CROSSING` / `VU_BORDER_CROSSING`
The card-side event remains the primary event. The VU-side event is suppressed from the processing channel but remains visible through `suppressedEvents`, `resolvedEvents`, and `eventMixingDecisions`. The module intentionally does not mix `CARD_VEHICLES_USED` and `IW_CYCLE` at event level. Instead, it reconciles the completed vehicle-usage intervals.
## Still intentionally unchanged Processing phases:
`CARD_VEHICLES_USED` and `IW_CYCLE` are still not mixed. They remain fully accepted in `vehicleUsageEvents` because they need a separate vehicle-usage rule later. 1. Split raw vehicle-usage intervals by source type:
- `CARD_VEHICLES_USED`
- `IW_CYCLE`
- `OTHER`
2. Normalize `CARD_VEHICLES_USED` technical midnight splits.
3. Reconcile normalized `CARD_VEHICLES_USED` intervals with `IW_CYCLE` intervals.
4. Produce effective vehicle-usage intervals for downstream processing.
## TACHOGRAPH_FILE_SESSION support ## CVU technical midnight split
The descriptor factory recognizes `TACHOGRAPH_FILE_SESSION` and `COMPOSITE_TACHOGRAPH_FILE_SESSION` events and derives card/VU extraction codes from `sourceKind` and event domain when no explicit `extractionCode` is present. The technical midnight split is handled only for `CARD_VEHICLES_USED` / CVU intervals, not for `IW_CYCLE`.
Pattern:
```text
CARD_VEHICLES_USED interval A ends at 23:59:59
CARD_VEHICLES_USED interval B starts at 00:00:00
same driver
same registration / compatible vehicle
max gap: 1 second
```
Result:
```text
A + B => one normalized CARD_VEHICLES_USED interval
```
## CVU vs IW reconciliation
After CVU normalization:
```text
normalized CARD_VEHICLES_USED interval
vs
IW_CYCLE interval
```
Rule:
```text
IW_CYCLE is primary for effective vehicle-usage identity.
CARD_VEHICLES_USED is fallback or corroborating evidence.
```
Matching currently supports exact or compatible start/end boundaries with a 60-second tolerance.
## New classes
```text
src/main/java/at/procon/eventhub/processing/eventprocessing/module/VehicleUsageReconciliationModule.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageIntervalDescriptor.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageIntervalDescriptorFactory.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageIntervalRole.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageIntervalSourceType.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageReconciliationDecisionDto.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageReconciliationResult.java
src/main/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageReconciliationService.java
src/test/java/at/procon/eventhub/processing/eventprocessing/vehicleusage/RuntimeVehicleUsageReconciliationServiceTest.java
```
## Modified existing files
```text
src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeModuleKeys.java
src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverVehicleUsageMergeModule.java
src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverWorkingTimeRuntimeProcessingPlan.java
```
## Notes
`vehicle-usage-merge` now consumes the effective intervals from `vehicle-usage-reconciliation` when that module has run. If the reconciliation module is omitted from a custom module list, `vehicle-usage-merge` falls back to raw `event-to-vehicle-usage-intervals` output.
Tests were added for:
- CVU technical midnight split coalescing
- CVU + IW reconciliation with IW as primary
- CVU fallback when IW is missing
- IW primary when CVU is missing

View File

@ -88,7 +88,11 @@ The important design point is that runtime processing is not tachograph-specific
"parameters": { "parameters": {
"significantDrivingMinutes": 3, "significantDrivingMinutes": 3,
"minimumRestPeriodMinutes": 720, "minimumRestPeriodMinutes": 720,
"includePartitionDebug": true "includePartitionDebug": true,
"includePartitionMetadata": true,
"includePartitionModuleResults": true,
"includeExecutionModuleResults": true,
"includeSupportEvidenceNormalization": true
} }
} }
``` ```
@ -204,3 +208,58 @@ The first source-neutral EPL modules are:
| `event-to-vehicle-usage-intervals` | `EPL` | `esper/runtime-driver-vehicle-usage-intervals.epl` | `driverVehicleUsageIntervals` | | `event-to-vehicle-usage-intervals` | `EPL` | `esper/runtime-driver-vehicle-usage-intervals.epl` | `driverVehicleUsageIntervals` |
These modules operate on canonical EventHub runtime events, not on tachograph-specific source rows. They are currently used as first-class phase modules in `driver-working-time-v1`; the final `driving-derived-projections` module remains a compatibility adapter over the validated working-time projection service until the remaining projection stages are split into direct EPL modules. These modules operate on canonical EventHub runtime events, not on tachograph-specific source rows. They are currently used as first-class phase modules in `driver-working-time-v1`; the final `driving-derived-projections` module remains a compatibility adapter over the validated working-time projection service until the remaining projection stages are split into direct EPL modules.
## Response shaping
`driver-working-time-v1` supports a small set of optional request parameters to trim diagnostic payloads from `/api/eventhub/runtime-processing/executions` and the legacy `/api/eventhub/runtime-processing/event-processing` adapter.
All of these default to `true` to preserve current behavior:
```text
includeExecutionModuleResults
includePartitionMetadata
includePartitionModuleResults
includeSupportEvidenceNormalization
includePartitionDebug
```
Meaning:
- `includeExecutionModuleResults`: include top-level `moduleResults` on `/executions`
- `includePartitionMetadata`: include `partitionResults[*].metadata`
- `includePartitionModuleResults`: include `partitionResults[*].moduleResults`
- `includeSupportEvidenceNormalization`: include `supportEvidenceNormalization` in each partition result payload
- `includePartitionDebug`: include `partitionDebug` in each partition result payload
Example slim response request:
```json
{
"processingPlanKey": "driver-working-time-v1",
"sourceSelection": {
"tenantKey": "default",
"driverKey": "12:12345678901234",
"occurredFrom": "2026-05-01T00:00:00Z",
"occurredTo": "2026-05-31T23:59:59Z",
"sourceInputs": [
{
"sourceFamily": "TACHOGRAPH_FILE_SESSION",
"eventBackend": "SOURCE_DB",
"sessionIds": [
"11111111-1111-1111-1111-111111111111"
]
}
]
},
"partitioning": {
"strategy": "DRIVER"
},
"parameters": {
"includeExecutionModuleResults": false,
"includePartitionMetadata": false,
"includePartitionModuleResults": false,
"includeSupportEvidenceNormalization": false,
"includePartitionDebug": false
}
}
```

View File

@ -66,6 +66,12 @@ public record UnifiedRuntimeDriverWorkingTimeScopeResultDto(
Object debug = entry.getValue().metadata().get("partitionDebug"); Object debug = entry.getValue().metadata().get("partitionDebug");
if (debug instanceof RuntimeDriverPartitionDebugDto partitionDebug) { if (debug instanceof RuntimeDriverPartitionDebugDto partitionDebug) {
debugByDriver.put(entry.getKey(), partitionDebug); debugByDriver.put(entry.getKey(), partitionDebug);
continue;
}
Object value = entry.getValue().result();
if (value instanceof UnifiedRuntimeDerivedProjectionResultDto projectionResult
&& projectionResult.partitionDebug() != null) {
debugByDriver.put(entry.getKey(), projectionResult.partitionDebug());
} }
} }
return debugByDriver; return debugByDriver;

View File

@ -2,6 +2,7 @@ package at.procon.eventhub.processing.eventprocessing.module;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval; import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingModuleDescriptorDto; import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingModuleDescriptorDto;
import at.procon.eventhub.processing.eventprocessing.vehicleusage.RuntimeVehicleUsageReconciliationResult;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@ -26,7 +27,7 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
return new RuntimeProcessingModuleDescriptorDto( return new RuntimeProcessingModuleDescriptorDto(
moduleKey(), moduleKey(),
"Vehicle usage merge", "Vehicle usage merge",
"Merges adjacent or continuous same-driver/same-vehicle usage intervals, including 23:59:59 to 00:00:00 continuations.", "Merges adjacent or continuous effective same-driver/same-vehicle usage intervals after source reconciliation.",
"JAVA", "JAVA",
Set.of("DriverWorkingTimeVehicleUsageInterval") Set.of("DriverWorkingTimeVehicleUsageInterval")
); );
@ -34,9 +35,7 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
@Override @Override
public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) { public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) {
Object output = context.previousResults().get(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS) == null Object output = sourceOutput(context);
? null
: context.previousResults().get(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS).output();
List<DriverWorkingTimeVehicleUsageInterval> intervals = castIntervals(output); List<DriverWorkingTimeVehicleUsageInterval> intervals = castIntervals(output);
List<DriverWorkingTimeVehicleUsageInterval> merged = merge(intervals); List<DriverWorkingTimeVehicleUsageInterval> merged = merge(intervals);
Map<String, Object> metadata = new LinkedHashMap<>(); Map<String, Object> metadata = new LinkedHashMap<>();
@ -51,8 +50,20 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
); );
} }
private Object sourceOutput(RuntimeProcessingModuleContext context) {
RuntimeProcessingModuleResult reconciled = context.previousResults().get(DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION);
if (reconciled != null) {
return reconciled.output();
}
RuntimeProcessingModuleResult raw = context.previousResults().get(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS);
return raw == null ? null : raw.output();
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private List<DriverWorkingTimeVehicleUsageInterval> castIntervals(Object output) { private List<DriverWorkingTimeVehicleUsageInterval> castIntervals(Object output) {
if (output instanceof RuntimeVehicleUsageReconciliationResult result) {
return result.effectiveVehicleUsageIntervals();
}
return output instanceof List<?> list return output instanceof List<?> list
? (List<DriverWorkingTimeVehicleUsageInterval>) list ? (List<DriverWorkingTimeVehicleUsageInterval>) list
: List.of(); : List.of();

View File

@ -6,6 +6,7 @@ public final class DriverWorkingTimeModuleKeys {
public static final String EVENT_EVIDENCE_MIXING = "event-evidence-mixing"; public static final String EVENT_EVIDENCE_MIXING = "event-evidence-mixing";
public static final String EVENT_TO_ACTIVITY_INTERVALS = "event-to-activity-intervals"; public static final String EVENT_TO_ACTIVITY_INTERVALS = "event-to-activity-intervals";
public static final String EVENT_TO_VEHICLE_USAGE_INTERVALS = "event-to-vehicle-usage-intervals"; public static final String EVENT_TO_VEHICLE_USAGE_INTERVALS = "event-to-vehicle-usage-intervals";
public static final String VEHICLE_USAGE_RECONCILIATION = "vehicle-usage-reconciliation";
public static final String VEHICLE_USAGE_MERGE = "vehicle-usage-merge"; public static final String VEHICLE_USAGE_MERGE = "vehicle-usage-merge";
public static final String VEHICLE_EVIDENCE_ATTACHMENT = "vehicle-evidence-attachment"; public static final String VEHICLE_EVIDENCE_ATTACHMENT = "vehicle-evidence-attachment";
public static final String SUPPORT_EVIDENCE_NORMALIZATION = "support-evidence-normalization"; public static final String SUPPORT_EVIDENCE_NORMALIZATION = "support-evidence-normalization";

View File

@ -0,0 +1,74 @@
package at.procon.eventhub.processing.eventprocessing.module;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingModuleDescriptorDto;
import at.procon.eventhub.processing.eventprocessing.vehicleusage.RuntimeVehicleUsageReconciliationResult;
import at.procon.eventhub.processing.eventprocessing.vehicleusage.RuntimeVehicleUsageReconciliationService;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class VehicleUsageReconciliationModule implements RuntimeProcessingModule {
private final RuntimeVehicleUsageReconciliationService reconciliationService;
@Autowired
public VehicleUsageReconciliationModule(RuntimeVehicleUsageReconciliationService reconciliationService) {
this.reconciliationService = reconciliationService;
}
/** Compatibility constructor for legacy tests that instantiate a local module registry. */
public VehicleUsageReconciliationModule() {
this(new RuntimeVehicleUsageReconciliationService());
}
@Override
public String moduleKey() {
return DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION;
}
@Override
public RuntimeProcessingModuleDescriptorDto descriptor() {
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Vehicle usage reconciliation",
"Normalizes CARD_VEHICLES_USED technical midnight splits, then reconciles normalized CARD_VEHICLES_USED intervals with IW_CYCLE intervals. IW_CYCLE is primary for effective vehicle usage; CARD_VEHICLES_USED remains fallback or corroborating evidence.",
"JAVA",
Set.of("RuntimeVehicleUsageReconciliationResult", "DriverVehicleUsageIntervalEvent")
);
}
@Override
public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) {
List<DriverWorkingTimeVehicleUsageInterval> rawIntervals = vehicleUsageIntervals(context);
RuntimeVehicleUsageReconciliationResult result = reconciliationService.reconcile(rawIntervals);
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("inputVehicleUsageIntervalCount", result.rawVehicleUsageIntervals().size());
metadata.put("normalizedCardVehicleUsedIntervalCount", result.normalizedCardVehicleUsedIntervals().size());
metadata.put("effectiveVehicleUsageIntervalCount", result.effectiveVehicleUsageIntervals().size());
metadata.put("suppressedSecondaryIntervalCount", result.suppressedSecondaryIntervals().size());
metadata.put("vehicleUsageReconciliationDecisionCount", result.vehicleUsageReconciliationDecisions().size());
metadata.put("vehicleUsageReconciliationDecisions", result.vehicleUsageReconciliationDecisions());
metadata.put("notes", result.notes());
return new RuntimeProcessingModuleResult(
moduleKey(),
RuntimeProcessingModuleStatus.SUCCESS,
result,
metadata,
result.warnings()
);
}
@SuppressWarnings("unchecked")
private List<DriverWorkingTimeVehicleUsageInterval> vehicleUsageIntervals(RuntimeProcessingModuleContext context) {
RuntimeProcessingModuleResult result = context.previousResults().get(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS);
if (result == null || !(result.output() instanceof List<?> list)) {
return List.of();
}
return (List<DriverWorkingTimeVehicleUsageInterval>) list;
}
}

View File

@ -14,6 +14,7 @@ import at.procon.eventhub.processing.eventprocessing.module.RuntimeProcessingMod
import at.procon.eventhub.processing.eventprocessing.module.DriverActivityIntervalsModule; import at.procon.eventhub.processing.eventprocessing.module.DriverActivityIntervalsModule;
import at.procon.eventhub.processing.eventprocessing.module.DriverVehicleUsageIntervalsModule; import at.procon.eventhub.processing.eventprocessing.module.DriverVehicleUsageIntervalsModule;
import at.procon.eventhub.processing.eventprocessing.module.DriverVehicleUsageMergeModule; import at.procon.eventhub.processing.eventprocessing.module.DriverVehicleUsageMergeModule;
import at.procon.eventhub.processing.eventprocessing.module.VehicleUsageReconciliationModule;
import at.procon.eventhub.processing.eventprocessing.module.VehicleEvidenceAttachmentModule; import at.procon.eventhub.processing.eventprocessing.module.VehicleEvidenceAttachmentModule;
import at.procon.eventhub.processing.eventprocessing.module.SupportEvidenceNormalizationModule; import at.procon.eventhub.processing.eventprocessing.module.SupportEvidenceNormalizationModule;
import at.procon.eventhub.processing.eventprocessing.module.DriverWorkingTimeDerivedProjectionsModule; import at.procon.eventhub.processing.eventprocessing.module.DriverWorkingTimeDerivedProjectionsModule;
@ -32,6 +33,10 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
public static final String PLAN_KEY = "driver-working-time-v1"; public static final String PLAN_KEY = "driver-working-time-v1";
public static final String ATTACH_VEHICLE_ONLY_EVENTS_ATTRIBUTE = "attachVehicleOnlyEvents"; public static final String ATTACH_VEHICLE_ONLY_EVENTS_ATTRIBUTE = "attachVehicleOnlyEvents";
public static final String VEHICLE_EVIDENCE_PADDING_MINUTES_ATTRIBUTE = "vehicleEvidencePaddingMinutes"; public static final String VEHICLE_EVIDENCE_PADDING_MINUTES_ATTRIBUTE = "vehicleEvidencePaddingMinutes";
public static final String INCLUDE_EXECUTION_MODULE_RESULTS_PARAMETER = "includeExecutionModuleResults";
public static final String INCLUDE_PARTITION_METADATA_PARAMETER = "includePartitionMetadata";
public static final String INCLUDE_PARTITION_MODULE_RESULTS_PARAMETER = "includePartitionModuleResults";
public static final String INCLUDE_SUPPORT_EVIDENCE_NORMALIZATION_PARAMETER = "includeSupportEvidenceNormalization";
private final RuntimeProcessingPipelineExecutor pipelineExecutor; private final RuntimeProcessingPipelineExecutor pipelineExecutor;
private final boolean includeRuntimeEventAssemblyModule; private final boolean includeRuntimeEventAssemblyModule;
@ -55,6 +60,7 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
this(new RuntimeProcessingPipelineExecutor(new RuntimeProcessingModuleRegistry(List.of( this(new RuntimeProcessingPipelineExecutor(new RuntimeProcessingModuleRegistry(List.of(
new DriverActivityIntervalsModule(), new DriverActivityIntervalsModule(),
new DriverVehicleUsageIntervalsModule(), new DriverVehicleUsageIntervalsModule(),
new VehicleUsageReconciliationModule(),
new DriverVehicleUsageMergeModule(), new DriverVehicleUsageMergeModule(),
new VehicleEvidenceAttachmentModule(), new VehicleEvidenceAttachmentModule(),
new SupportEvidenceNormalizationModule(), new SupportEvidenceNormalizationModule(),
@ -137,11 +143,18 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
"ESPER", "ESPER",
Set.of("DriverVehicleUsageIntervalEvent") Set.of("DriverVehicleUsageIntervalEvent")
), ),
new RuntimeProcessingModuleDescriptorDto(
DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION,
"Vehicle usage reconciliation",
"Coalesces CARD_VEHICLES_USED technical midnight splits before reconciling normalized CARD_VEHICLES_USED intervals with IW_CYCLE intervals. IW_CYCLE is primary; CARD_VEHICLES_USED is fallback or corroborating evidence.",
"JAVA",
Set.of("RuntimeVehicleUsageReconciliationResult", "DriverVehicleUsageIntervalEvent")
),
new RuntimeProcessingModuleDescriptorDto( new RuntimeProcessingModuleDescriptorDto(
DriverWorkingTimeModuleKeys.VEHICLE_USAGE_MERGE, DriverWorkingTimeModuleKeys.VEHICLE_USAGE_MERGE,
"Vehicle usage merge", "Vehicle usage merge",
"Merges adjacent same-driver/same-vehicle usage intervals, including 23:59:59 to 00:00:00 continuations. Currently delegated to the derived projections adapter.", "Merges adjacent effective same-driver/same-vehicle usage intervals after vehicle-usage source reconciliation.",
"JAVA/ESPER", "JAVA",
Set.of("DriverVehicleUsageIntervalEvent") Set.of("DriverVehicleUsageIntervalEvent")
), ),
new RuntimeProcessingModuleDescriptorDto( new RuntimeProcessingModuleDescriptorDto(
@ -176,9 +189,13 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
"minimumRestPeriodMinutes", "minimumRestPeriodMinutes",
"attachVehicleOnlyEvents", "attachVehicleOnlyEvents",
"vehicleEvidencePaddingMinutes", "vehicleEvidencePaddingMinutes",
INCLUDE_EXECUTION_MODULE_RESULTS_PARAMETER,
INCLUDE_PARTITION_METADATA_PARAMETER,
INCLUDE_PARTITION_MODULE_RESULTS_PARAMETER,
"includeActivityIntervals", "includeActivityIntervals",
"includeDrivingIntervals", "includeDrivingIntervals",
"includePartitionDebug", "includePartitionDebug",
INCLUDE_SUPPORT_EVIDENCE_NORMALIZATION_PARAMETER,
"eventMixingMode" "eventMixingMode"
); );
} }
@ -195,6 +212,26 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
request.partitioning(), request.partitioning(),
request.parameters() request.parameters()
); );
boolean includeExecutionModuleResults = booleanParameter(
request.parameters(),
INCLUDE_EXECUTION_MODULE_RESULTS_PARAMETER,
true
);
boolean includePartitionMetadata = booleanParameter(
request.parameters(),
INCLUDE_PARTITION_METADATA_PARAMETER,
true
);
boolean includePartitionModuleResults = booleanParameter(
request.parameters(),
INCLUDE_PARTITION_MODULE_RESULTS_PARAMETER,
true
);
boolean includeSupportEvidenceNormalization = booleanParameter(
request.parameters(),
INCLUDE_SUPPORT_EVIDENCE_NORMALIZATION_PARAMETER,
true
);
int vehicleEvidencePaddingMinutes = resolveVehicleEvidencePaddingMinutes( int vehicleEvidencePaddingMinutes = resolveVehicleEvidencePaddingMinutes(
request.sourceSelection(), request.sourceSelection(),
request.partitioning(), request.partitioning(),
@ -228,26 +265,23 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
Map<String, RuntimeEventProcessingPartitionResultDto> partitionResults = new LinkedHashMap<>(); Map<String, RuntimeEventProcessingPartitionResultDto> partitionResults = new LinkedHashMap<>();
workingTimeResult.driverResults().forEach((driverKey, driverResult) -> { workingTimeResult.driverResults().forEach((driverKey, driverResult) -> {
Map<String, Object> metadata = new LinkedHashMap<>(); UnifiedRuntimeDerivedProjectionResultDto shapedDriverResult = shapeDriverResult(
metadata.put("projectionResultType", driverResult.projection() == null ? "NONE" : "DriverWorkingTimeProcessingResultDto"); driverResult,
metadata.put("driverSeedEventCount", driverResult.driverSeedEventCount()); includeSupportEvidenceNormalization,
metadata.put("expandedVehicleEventCount", driverResult.expandedVehicleEventCount()); includePartitionDebug
metadata.put("mergedEventCount", driverResult.mergedEventCount()); );
if (driverResult.supportEvidenceNormalization() != null) { Map<String, Object> metadata = includePartitionMetadata
metadata.put("supportEvidenceNormalization", driverResult.supportEvidenceNormalization()); ? partitionMetadata(shapedDriverResult)
} : Map.of();
if (driverResult.partitionDebug() != null) {
metadata.put("partitionDebug", driverResult.partitionDebug());
}
partitionResults.put( partitionResults.put(
driverKey, driverKey,
new RuntimeEventProcessingPartitionResultDto( new RuntimeEventProcessingPartitionResultDto(
"DRIVER", "DRIVER",
driverKey, driverKey,
"UnifiedRuntimeDerivedProjectionResultDto", "UnifiedRuntimeDerivedProjectionResultDto",
driverResult, shapedDriverResult,
metadata, metadata,
partitionModuleResults(driverResult) includePartitionModuleResults ? partitionModuleResults(shapedDriverResult) : Map.of()
) )
); );
}); });
@ -261,7 +295,7 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
workingTimeResult.selectedDriverCount(), workingTimeResult.selectedDriverCount(),
workingTimeResult.discoveredVehicleCount(), workingTimeResult.discoveredVehicleCount(),
workingTimeResult.discoveredVehicles(), workingTimeResult.discoveredVehicles(),
sanitizeExecutionModuleResults(moduleResults), includeExecutionModuleResults ? sanitizeExecutionModuleResults(moduleResults) : Map.of(),
partitionResults, partitionResults,
workingTimeResult.notes(), workingTimeResult.notes(),
workingTimeResult.warnings() workingTimeResult.warnings()
@ -355,6 +389,42 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
return results; return results;
} }
private Map<String, Object> partitionMetadata(
UnifiedRuntimeDerivedProjectionResultDto driverResult
) {
LinkedHashMap<String, Object> metadata = new LinkedHashMap<>();
metadata.put("projectionResultType", driverResult.projection() == null ? "NONE" : "DriverWorkingTimeProcessingResultDto");
metadata.put("driverSeedEventCount", driverResult.driverSeedEventCount());
metadata.put("expandedVehicleEventCount", driverResult.expandedVehicleEventCount());
metadata.put("mergedEventCount", driverResult.mergedEventCount());
if (driverResult.supportEvidenceNormalization() != null) {
metadata.put("supportEvidenceNormalization", driverResult.supportEvidenceNormalization());
}
if (driverResult.partitionDebug() != null) {
metadata.put("partitionDebug", driverResult.partitionDebug());
}
return metadata;
}
private UnifiedRuntimeDerivedProjectionResultDto shapeDriverResult(
UnifiedRuntimeDerivedProjectionResultDto driverResult,
boolean includeSupportEvidenceNormalization,
boolean includePartitionDebug
) {
return new UnifiedRuntimeDerivedProjectionResultDto(
driverResult.request(),
driverResult.driverSeedEventCount(),
driverResult.discoveredVehicleCount(),
driverResult.expandedVehicleEventCount(),
driverResult.mergedEventCount(),
driverResult.discoveredVehicles(),
driverResult.projection(),
driverResult.notes(),
includeSupportEvidenceNormalization ? driverResult.supportEvidenceNormalization() : null,
includePartitionDebug ? driverResult.partitionDebug() : null
);
}
public UnifiedRuntimeProcessingApiRequest applyExecutionRequest( public UnifiedRuntimeProcessingApiRequest applyExecutionRequest(
UnifiedRuntimeProcessingApiRequest sourceSelection, UnifiedRuntimeProcessingApiRequest sourceSelection,
RuntimeEventPartitioningApiRequest partitioning, RuntimeEventPartitioningApiRequest partitioning,

View File

@ -0,0 +1,19 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import java.time.OffsetDateTime;
public record RuntimeVehicleUsageIntervalDescriptor(
DriverWorkingTimeVehicleUsageInterval interval,
RuntimeVehicleUsageIntervalSourceType sourceType,
String sourceKind,
String driverKey,
String registrationKey,
String vehicleKey,
OffsetDateTime startedAt,
OffsetDateTime endedAt
) {
public String intervalId() {
return interval == null ? null : interval.intervalId();
}
}

View File

@ -0,0 +1,64 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.stereotype.Component;
@Component
public class RuntimeVehicleUsageIntervalDescriptorFactory {
public RuntimeVehicleUsageIntervalDescriptor describe(DriverWorkingTimeVehicleUsageInterval interval) {
if (interval == null) {
return null;
}
return new RuntimeVehicleUsageIntervalDescriptor(
interval,
sourceType(interval),
interval.sourceKind(),
interval.driverKey(),
interval.registrationKey(),
interval.vehicleKey(),
interval.startedAt(),
interval.endedAt()
);
}
private RuntimeVehicleUsageIntervalSourceType sourceType(DriverWorkingTimeVehicleUsageInterval interval) {
String sourceKind = normalize(interval.sourceKind());
List<String> identifiers = identifiers(interval).stream().map(this::normalize).toList();
if (identifiers.stream().anyMatch(value -> value.contains("CARD_VEHICLES_USED"))
|| "CARD_VEHICLES_USED".equals(sourceKind)
|| "DRIVER_CARD".equals(sourceKind)) {
return RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED;
}
if (identifiers.stream().anyMatch(value -> value.contains("IW_CYCLE"))
|| "IW_CYCLE".equals(sourceKind)
|| "VEHICLE_UNIT".equals(sourceKind)) {
return RuntimeVehicleUsageIntervalSourceType.IW_CYCLE;
}
return RuntimeVehicleUsageIntervalSourceType.OTHER;
}
private List<String> identifiers(DriverWorkingTimeVehicleUsageInterval interval) {
List<String> result = new ArrayList<>();
add(result, interval.intervalId());
add(result, interval.firstSourceIntervalId());
add(result, interval.lastSourceIntervalId());
if (interval.sourceIntervalIds() != null) {
interval.sourceIntervalIds().forEach(value -> add(result, value));
}
return result;
}
private void add(List<String> values, String value) {
if (value != null && !value.isBlank()) {
values.add(value);
}
}
private String normalize(String value) {
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
}
}

View File

@ -0,0 +1,11 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
public enum RuntimeVehicleUsageIntervalRole {
PRIMARY,
FUSED_PRIMARY,
FALLBACK_PRIMARY,
CORROBORATING_SECONDARY,
SUPPRESSED_DUPLICATE,
UNCHANGED,
CONFLICTING_EVIDENCE
}

View File

@ -0,0 +1,7 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
public enum RuntimeVehicleUsageIntervalSourceType {
CARD_VEHICLES_USED,
IW_CYCLE,
OTHER
}

View File

@ -0,0 +1,26 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
import java.time.OffsetDateTime;
import java.util.List;
public record RuntimeVehicleUsageReconciliationDecisionDto(
String ruleId,
String decision,
String equivalenceType,
String primaryIntervalId,
String primarySourceType,
List<String> secondaryIntervalIds,
List<String> secondarySourceTypes,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
Long startDeltaSeconds,
Long endDeltaSeconds,
String reason,
List<String> warnings
) {
public RuntimeVehicleUsageReconciliationDecisionDto {
secondaryIntervalIds = secondaryIntervalIds == null ? List.of() : List.copyOf(secondaryIntervalIds);
secondarySourceTypes = secondarySourceTypes == null ? List.of() : List.copyOf(secondarySourceTypes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
}

View File

@ -0,0 +1,24 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import java.util.List;
public record RuntimeVehicleUsageReconciliationResult(
List<DriverWorkingTimeVehicleUsageInterval> rawVehicleUsageIntervals,
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardVehicleUsedIntervals,
List<DriverWorkingTimeVehicleUsageInterval> effectiveVehicleUsageIntervals,
List<DriverWorkingTimeVehicleUsageInterval> suppressedSecondaryIntervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> vehicleUsageReconciliationDecisions,
List<String> notes,
List<String> warnings
) {
public RuntimeVehicleUsageReconciliationResult {
rawVehicleUsageIntervals = rawVehicleUsageIntervals == null ? List.of() : List.copyOf(rawVehicleUsageIntervals);
normalizedCardVehicleUsedIntervals = normalizedCardVehicleUsedIntervals == null ? List.of() : List.copyOf(normalizedCardVehicleUsedIntervals);
effectiveVehicleUsageIntervals = effectiveVehicleUsageIntervals == null ? List.of() : List.copyOf(effectiveVehicleUsageIntervals);
suppressedSecondaryIntervals = suppressedSecondaryIntervals == null ? List.of() : List.copyOf(suppressedSecondaryIntervals);
vehicleUsageReconciliationDecisions = vehicleUsageReconciliationDecisions == null ? List.of() : List.copyOf(vehicleUsageReconciliationDecisions);
notes = notes == null ? List.of() : List.copyOf(notes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
}

View File

@ -0,0 +1,474 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Service;
@Service
public class RuntimeVehicleUsageReconciliationService {
public static final String RULE_CVU_MIDNIGHT_CONTINUATION = "tachograph.vehicle-usage.card-vehicles-used.midnight-continuation";
public static final String RULE_CVU_IW_EXACT_OR_COMPATIBLE = "tachograph.vehicle-usage.card-vehicles-used-iw-cycle.exact-or-compatible";
public static final String RULE_CVU_FALLBACK = "tachograph.vehicle-usage.card-vehicles-used-fallback";
public static final String RULE_IW_PRIMARY = "tachograph.vehicle-usage.iw-cycle-primary";
private static final long MATCH_TOLERANCE_SECONDS = 60L;
private final RuntimeVehicleUsageIntervalDescriptorFactory descriptorFactory;
public RuntimeVehicleUsageReconciliationService() {
this(new RuntimeVehicleUsageIntervalDescriptorFactory());
}
public RuntimeVehicleUsageReconciliationService(RuntimeVehicleUsageIntervalDescriptorFactory descriptorFactory) {
this.descriptorFactory = descriptorFactory;
}
public RuntimeVehicleUsageReconciliationResult reconcile(List<DriverWorkingTimeVehicleUsageInterval> intervals) {
List<DriverWorkingTimeVehicleUsageInterval> raw = intervals == null ? List.of() : intervals.stream()
.filter(Objects::nonNull)
.sorted(intervalComparator())
.toList();
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions = new ArrayList<>();
List<String> warnings = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> cardIntervals = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> iwIntervals = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> otherIntervals = new ArrayList<>();
for (DriverWorkingTimeVehicleUsageInterval interval : raw) {
RuntimeVehicleUsageIntervalDescriptor descriptor = descriptorFactory.describe(interval);
RuntimeVehicleUsageIntervalSourceType sourceType = descriptor == null
? RuntimeVehicleUsageIntervalSourceType.OTHER
: descriptor.sourceType();
switch (sourceType) {
case CARD_VEHICLES_USED -> cardIntervals.add(interval);
case IW_CYCLE -> iwIntervals.add(interval);
case OTHER -> otherIntervals.add(interval);
}
}
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardIntervals = normalizeCardVehicleUsed(cardIntervals, decisions);
ReconciliationStepResult reconciled = reconcileCardWithIw(normalizedCardIntervals, iwIntervals, decisions, warnings);
List<DriverWorkingTimeVehicleUsageInterval> effective = new ArrayList<>();
effective.addAll(reconciled.effectiveIntervals());
effective.addAll(otherIntervals);
effective.sort(intervalComparator());
List<String> notes = List.of(
"Vehicle usage reconciliation first coalesced CARD_VEHICLES_USED technical midnight splits, then reconciled normalized CARD_VEHICLES_USED intervals with IW_CYCLE intervals."
);
return new RuntimeVehicleUsageReconciliationResult(
raw,
normalizedCardIntervals,
effective,
reconciled.suppressedIntervals(),
decisions,
notes,
warnings
);
}
private List<DriverWorkingTimeVehicleUsageInterval> normalizeCardVehicleUsed(
List<DriverWorkingTimeVehicleUsageInterval> intervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
List<DriverWorkingTimeVehicleUsageInterval> sorted = intervals.stream()
.filter(Objects::nonNull)
.sorted(intervalComparator())
.toList();
List<DriverWorkingTimeVehicleUsageInterval> normalized = new ArrayList<>();
for (DriverWorkingTimeVehicleUsageInterval next : sorted) {
if (normalized.isEmpty()) {
normalized.add(next);
continue;
}
DriverWorkingTimeVehicleUsageInterval current = normalized.get(normalized.size() - 1);
if (canMergeCardVehicleUsedTechnicalMidnightSplit(current, next)) {
DriverWorkingTimeVehicleUsageInterval merged = mergeCardVehicleUsedTechnicalSplit(current, next);
normalized.set(normalized.size() - 1, merged);
decisions.add(new RuntimeVehicleUsageReconciliationDecisionDto(
RULE_CVU_MIDNIGHT_CONTINUATION,
"COALESCED_TECHNICAL_MIDNIGHT_SPLIT",
"CARD_VEHICLES_USED_MIDNIGHT_CONTINUATION",
merged.intervalId(),
RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED.name(),
List.of(current.intervalId(), next.intervalId()),
List.of(RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED.name(), RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED.name()),
merged.startedAt(),
merged.endedAt(),
deltaSeconds(current.endedAt(), next.startedAt()),
null,
"CARD_VEHICLES_USED contained a technical 23:59:59/00:00:00 split for the same driver and vehicle; it was normalized before IW_CYCLE reconciliation.",
List.of()
));
} else {
normalized.add(next);
}
}
return List.copyOf(normalized);
}
private ReconciliationStepResult reconcileCardWithIw(
List<DriverWorkingTimeVehicleUsageInterval> cardIntervals,
List<DriverWorkingTimeVehicleUsageInterval> iwIntervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions,
List<String> warnings
) {
List<DriverWorkingTimeVehicleUsageInterval> effective = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> suppressed = new ArrayList<>();
Set<String> usedIwKeys = new LinkedHashSet<>();
Map<String, DriverWorkingTimeVehicleUsageInterval> iwByKey = new LinkedHashMap<>();
for (DriverWorkingTimeVehicleUsageInterval iw : iwIntervals == null ? List.<DriverWorkingTimeVehicleUsageInterval>of() : iwIntervals) {
iwByKey.put(intervalIdentity(iw), iw);
}
for (DriverWorkingTimeVehicleUsageInterval card : cardIntervals == null ? List.<DriverWorkingTimeVehicleUsageInterval>of() : cardIntervals) {
DriverWorkingTimeVehicleUsageInterval matchingIw = bestMatchingIw(card, iwIntervals, usedIwKeys);
if (matchingIw != null) {
usedIwKeys.add(intervalIdentity(matchingIw));
DriverWorkingTimeVehicleUsageInterval fused = fuseIwPrimary(matchingIw, card);
effective.add(fused);
suppressed.add(card);
decisions.add(new RuntimeVehicleUsageReconciliationDecisionDto(
RULE_CVU_IW_EXACT_OR_COMPATIBLE,
"FUSED_PRIMARY_SELECTED",
equivalenceType(card, matchingIw),
fused.intervalId(),
RuntimeVehicleUsageIntervalSourceType.IW_CYCLE.name(),
List.of(card.intervalId()),
List.of(RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED.name()),
fused.startedAt(),
fused.endedAt(),
absoluteDeltaSeconds(card.startedAt(), matchingIw.startedAt()),
absoluteDeltaSeconds(card.endedAt(), matchingIw.endedAt()),
"Normalized CARD_VEHICLES_USED interval matches IW_CYCLE. IW_CYCLE is primary for vehicle usage identity; CARD_VEHICLES_USED remains corroborating source evidence.",
vehicleConflictWarnings(card, matchingIw)
));
warnings.addAll(vehicleConflictWarnings(card, matchingIw));
} else {
effective.add(card);
decisions.add(new RuntimeVehicleUsageReconciliationDecisionDto(
RULE_CVU_FALLBACK,
"FALLBACK_PRIMARY_SELECTED",
"NO_COMPATIBLE_IW_CYCLE",
card.intervalId(),
RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED.name(),
List.of(),
List.of(),
card.startedAt(),
card.endedAt(),
null,
null,
"No compatible IW_CYCLE interval was found; CARD_VEHICLES_USED is kept as fallback vehicle-usage evidence.",
List.of()
));
}
}
iwByKey.forEach((key, iw) -> {
if (!usedIwKeys.contains(key)) {
effective.add(iw);
decisions.add(new RuntimeVehicleUsageReconciliationDecisionDto(
RULE_IW_PRIMARY,
"PRIMARY_SELECTED",
"NO_COMPATIBLE_CARD_VEHICLES_USED",
iw.intervalId(),
RuntimeVehicleUsageIntervalSourceType.IW_CYCLE.name(),
List.of(),
List.of(),
iw.startedAt(),
iw.endedAt(),
null,
null,
"No compatible CARD_VEHICLES_USED interval was found; IW_CYCLE is kept as primary vehicle-usage evidence.",
List.of()
));
}
});
effective.sort(intervalComparator());
suppressed.sort(intervalComparator());
return new ReconciliationStepResult(List.copyOf(effective), List.copyOf(suppressed));
}
private DriverWorkingTimeVehicleUsageInterval bestMatchingIw(
DriverWorkingTimeVehicleUsageInterval card,
List<DriverWorkingTimeVehicleUsageInterval> iwIntervals,
Set<String> usedIwKeys
) {
if (card == null || iwIntervals == null || iwIntervals.isEmpty()) {
return null;
}
return iwIntervals.stream()
.filter(Objects::nonNull)
.filter(iw -> !usedIwKeys.contains(intervalIdentity(iw)))
.filter(iw -> canReconcileCardWithIw(card, iw))
.min(Comparator.comparingLong(iw -> matchScore(card, iw)))
.orElse(null);
}
private boolean canReconcileCardWithIw(
DriverWorkingTimeVehicleUsageInterval card,
DriverWorkingTimeVehicleUsageInterval iw
) {
if (card == null || iw == null || card.startedAt() == null || card.endedAt() == null
|| iw.startedAt() == null || iw.endedAt() == null) {
return false;
}
if (!Objects.equals(card.driverKey(), iw.driverKey())) {
return false;
}
if (!compatibleVehicleIdentity(card, iw)) {
return false;
}
long startDelta = Math.abs(card.startedAt().toEpochSecond() - iw.startedAt().toEpochSecond());
long endDelta = Math.abs(card.endedAt().toEpochSecond() - iw.endedAt().toEpochSecond());
return startDelta <= MATCH_TOLERANCE_SECONDS && endDelta <= MATCH_TOLERANCE_SECONDS;
}
private boolean canMergeCardVehicleUsedTechnicalMidnightSplit(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
if (left == null || right == null || left.endedAt() == null || right.startedAt() == null) {
return false;
}
if (!Objects.equals(left.driverKey(), right.driverKey())) {
return false;
}
if (!compatibleVehicleIdentity(left, right)) {
return false;
}
if (!isEndOfDay(left.endedAt()) || !isStartOfDay(right.startedAt())) {
return false;
}
long gap = right.startedAt().toEpochSecond() - left.endedAt().toEpochSecond();
return gap >= 0 && gap <= 1;
}
private DriverWorkingTimeVehicleUsageInterval mergeCardVehicleUsedTechnicalSplit(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
OffsetDateTime end = right.endedAt() == null ? left.endedAt() : right.endedAt();
return new DriverWorkingTimeVehicleUsageInterval(
firstNonNull(left.sessionId(), right.sessionId()),
firstNonBlank(left.driverKey(), right.driverKey()),
mergedIntervalId("CVU_MERGED", left, right),
firstNonBlank(left.firstSourceIntervalId(), left.intervalId()),
firstNonBlank(right.lastSourceIntervalId(), right.intervalId(), left.lastSourceIntervalId()),
left.startedAt(),
end,
left.startedAtEpochSecond(),
end == null ? null : end.toEpochSecond(),
end == null ? left.durationSeconds() : end.toEpochSecond() - left.startedAtEpochSecond(),
firstNonNull(left.odometerBeginKm(), right.odometerBeginKm()),
firstNonNull(right.odometerEndKm(), left.odometerEndKm()),
firstNonBlank(left.registrationKey(), right.registrationKey()),
firstNonBlank(left.vehicleKey(), right.vehicleKey()),
firstNonBlank(left.sourceKind(), right.sourceKind()),
sourceIntervalIds(left, right)
);
}
private DriverWorkingTimeVehicleUsageInterval fuseIwPrimary(
DriverWorkingTimeVehicleUsageInterval iw,
DriverWorkingTimeVehicleUsageInterval card
) {
return new DriverWorkingTimeVehicleUsageInterval(
firstNonNull(iw.sessionId(), card.sessionId()),
firstNonBlank(iw.driverKey(), card.driverKey()),
iw.intervalId(),
firstNonBlank(iw.firstSourceIntervalId(), iw.intervalId()),
firstNonBlank(iw.lastSourceIntervalId(), iw.intervalId()),
iw.startedAt(),
iw.endedAt(),
iw.startedAtEpochSecond(),
iw.endedAtEpochSecond(),
iw.durationSeconds(),
firstNonNull(iw.odometerBeginKm(), card.odometerBeginKm()),
firstNonNull(iw.odometerEndKm(), card.odometerEndKm()),
firstNonBlank(iw.registrationKey(), card.registrationKey()),
firstNonBlank(iw.vehicleKey(), card.vehicleKey()),
firstNonBlank(iw.sourceKind(), RuntimeVehicleUsageIntervalSourceType.IW_CYCLE.name()),
sourceIntervalIds(iw, card)
);
}
private boolean compatibleVehicleIdentity(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
boolean registrationCompatible = compatibleNullable(left.registrationKey(), right.registrationKey());
boolean vehicleCompatible = compatibleNullable(left.vehicleKey(), right.vehicleKey());
if (hasText(left.registrationKey()) && hasText(right.registrationKey())) {
return registrationCompatible && vehicleCompatible;
}
if (hasText(left.vehicleKey()) && hasText(right.vehicleKey())) {
return vehicleCompatible && registrationCompatible;
}
return registrationCompatible || vehicleCompatible;
}
private boolean compatibleNullable(String left, String right) {
return !hasText(left) || !hasText(right) || Objects.equals(left, right);
}
private List<String> vehicleConflictWarnings(
DriverWorkingTimeVehicleUsageInterval card,
DriverWorkingTimeVehicleUsageInterval iw
) {
List<String> result = new ArrayList<>();
if (hasText(card.registrationKey()) && hasText(iw.registrationKey())
&& !Objects.equals(card.registrationKey(), iw.registrationKey())) {
result.add("CARD_VEHICLES_USED and IW_CYCLE identify different registrations for overlapping vehicle usage intervals: "
+ card.registrationKey() + " vs " + iw.registrationKey() + ".");
}
if (hasText(card.vehicleKey()) && hasText(iw.vehicleKey())
&& !Objects.equals(card.vehicleKey(), iw.vehicleKey())) {
result.add("CARD_VEHICLES_USED and IW_CYCLE identify different vehicle keys for overlapping vehicle usage intervals: "
+ card.vehicleKey() + " vs " + iw.vehicleKey() + ".");
}
return List.copyOf(result);
}
private String equivalenceType(DriverWorkingTimeVehicleUsageInterval card, DriverWorkingTimeVehicleUsageInterval iw) {
Long start = absoluteDeltaSeconds(card.startedAt(), iw.startedAt());
Long end = absoluteDeltaSeconds(card.endedAt(), iw.endedAt());
if ((start == null || start == 0L) && (end == null || end == 0L)) {
return "EXACT_INTERVAL_BOUNDARIES";
}
return "COMPATIBLE_INTERVAL_BOUNDARIES";
}
private long matchScore(DriverWorkingTimeVehicleUsageInterval card, DriverWorkingTimeVehicleUsageInterval iw) {
Long start = absoluteDeltaSeconds(card.startedAt(), iw.startedAt());
Long end = absoluteDeltaSeconds(card.endedAt(), iw.endedAt());
return (start == null ? Long.MAX_VALUE / 4 : start) + (end == null ? Long.MAX_VALUE / 4 : end);
}
private Long absoluteDeltaSeconds(OffsetDateTime left, OffsetDateTime right) {
if (left == null || right == null) {
return null;
}
return Math.abs(left.toEpochSecond() - right.toEpochSecond());
}
private Long deltaSeconds(OffsetDateTime left, OffsetDateTime right) {
if (left == null || right == null) {
return null;
}
return right.toEpochSecond() - left.toEpochSecond();
}
private boolean isEndOfDay(OffsetDateTime value) {
return value != null && value.toLocalTime().equals(LocalTime.of(23, 59, 59));
}
private boolean isStartOfDay(OffsetDateTime value) {
return value != null && value.toLocalTime().equals(LocalTime.MIDNIGHT);
}
private Comparator<DriverWorkingTimeVehicleUsageInterval> intervalComparator() {
return Comparator
.comparing(DriverWorkingTimeVehicleUsageInterval::startedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(DriverWorkingTimeVehicleUsageInterval::endedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(DriverWorkingTimeVehicleUsageInterval::driverKey, Comparator.nullsLast(String::compareTo))
.thenComparing(DriverWorkingTimeVehicleUsageInterval::intervalId, Comparator.nullsLast(String::compareTo));
}
private List<String> sourceIntervalIds(DriverWorkingTimeVehicleUsageInterval... intervals) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (DriverWorkingTimeVehicleUsageInterval interval : intervals) {
if (interval == null) {
continue;
}
add(values, interval.intervalId());
add(values, interval.firstSourceIntervalId());
add(values, interval.lastSourceIntervalId());
if (interval.sourceIntervalIds() != null) {
interval.sourceIntervalIds().forEach(value -> add(values, value));
}
}
return List.copyOf(values);
}
private void add(Set<String> values, String value) {
if (value != null && !value.isBlank()) {
values.add(value);
}
}
private String mergedIntervalId(
String prefix,
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
String driver = firstNonBlank(left.driverKey(), right.driverKey(), "driver");
String registration = firstNonBlank(left.registrationKey(), right.registrationKey(), "vehicle");
String start = left.startedAt() == null ? "open" : left.startedAt().toString();
String end = right.endedAt() == null ? "open" : right.endedAt().toString();
return prefix + "|" + driver + "|" + registration + "|" + start + "|" + end;
}
private String intervalIdentity(DriverWorkingTimeVehicleUsageInterval interval) {
if (interval == null) {
return "null";
}
String id = interval.intervalId();
if (id != null && !id.isBlank()) {
return id;
}
return interval.driverKey() + "|" + interval.registrationKey() + "|" + interval.vehicleKey()
+ "|" + interval.startedAt() + "|" + interval.endedAt();
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
@SafeVarargs
private final <T> T firstNonNull(T... values) {
if (values == null) {
return null;
}
for (T value : values) {
if (value != null) {
return value;
}
}
return null;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private record ReconciliationStepResult(
List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals,
List<DriverWorkingTimeVehicleUsageInterval> suppressedIntervals
) {
}
}

View File

@ -2,10 +2,15 @@ package at.procon.eventhub.processing.eventprocessing.plan;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.processing.dto.RuntimeDriverPartitionDebugDto;
import at.procon.eventhub.processing.dto.RuntimeSupportEvidenceNormalizationDebugDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDriverWorkingTimeScopeResultDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventPartitioningApiRequest; import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventPartitioningApiRequest;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.processing.service.RuntimeDriverWorkingTimeScopeProcessingService; import at.procon.eventhub.processing.service.RuntimeDriverWorkingTimeScopeProcessingService;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
@ -13,13 +18,15 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
class DriverWorkingTimeRuntimeProcessingPlanTest { class DriverWorkingTimeRuntimeProcessingPlanTest {
@Test @Test
void applyExecutionRequestDoesNotRewriteScopeExpansionFromAttachmentFlag() { void applyExecutionRequestDoesNotRewriteScopeExpansionFromAttachmentFlag() {
DriverWorkingTimeRuntimeProcessingPlan plan = new DriverWorkingTimeRuntimeProcessingPlan( DriverWorkingTimeRuntimeProcessingPlan plan = new DriverWorkingTimeRuntimeProcessingPlan(
org.mockito.Mockito.mock(RuntimeDriverWorkingTimeScopeProcessingService.class) Mockito.mock(RuntimeDriverWorkingTimeScopeProcessingService.class)
); );
UnifiedRuntimeProcessingApiRequest scope = new UnifiedRuntimeProcessingApiRequest( UnifiedRuntimeProcessingApiRequest scope = new UnifiedRuntimeProcessingApiRequest(
UUID.randomUUID(), UUID.randomUUID(),
@ -69,4 +76,143 @@ class DriverWorkingTimeRuntimeProcessingPlanTest {
assertThat(resolved.expandVehicleEvents()).isTrue(); assertThat(resolved.expandVehicleEvents()).isTrue();
assertThat(resolved.vehicleExpansionPaddingMinutes()).isEqualTo(15); assertThat(resolved.vehicleExpansionPaddingMinutes()).isEqualTo(15);
} }
@Test
void executeCanOmitExtendedPartitionPayloads() {
RuntimeDriverWorkingTimeScopeProcessingService scopeService = Mockito.mock(RuntimeDriverWorkingTimeScopeProcessingService.class);
DriverWorkingTimeRuntimeProcessingPlan plan = new DriverWorkingTimeRuntimeProcessingPlan(scopeService);
RuntimeDriverPartitionDebugDto partitionDebug = new RuntimeDriverPartitionDebugDto(
"12:DRIVER-1",
5,
2,
1,
1,
0,
6,
List.of(),
List.of(),
List.of("debug note"),
List.of()
);
RuntimeSupportEvidenceNormalizationDebugDto normalizationDebug = new RuntimeSupportEvidenceNormalizationDebugDto(
6,
2,
4,
List.of("normalization note")
);
UnifiedRuntimeProcessingRequest processedRequest = processedRequest();
UnifiedRuntimeDerivedProjectionResultDto driverResult = new UnifiedRuntimeDerivedProjectionResultDto(
processedRequest,
5,
1,
1,
6,
List.of(),
null,
List.of("driver processed"),
normalizationDebug,
partitionDebug
);
Mockito.when(scopeService.processScope(ArgumentMatchers.any(), ArgumentMatchers.anyBoolean()))
.thenReturn(new UnifiedRuntimeDriverWorkingTimeScopeResultDto(
processedRequest,
6,
1,
1,
List.of(),
Map.of("12:DRIVER-1", driverResult),
Map.of("12:DRIVER-1", partitionDebug),
List.of("scope processed"),
List.of()
));
RuntimeProcessingExecutionResultDto result = plan.execute(new RuntimeProcessingExecutionApiRequest(
DriverWorkingTimeRuntimeProcessingPlan.PLAN_KEY,
sourceSelection(),
new RuntimeEventPartitioningApiRequest(
RuntimeEventPartitioningStrategy.DRIVER,
Set.of("12:DRIVER-1"),
false,
Set.of("12:DRIVER-1"),
false,
Set.of(),
false,
true,
15,
false
),
List.of(),
Map.of(
DriverWorkingTimeRuntimeProcessingPlan.INCLUDE_EXECUTION_MODULE_RESULTS_PARAMETER, false,
DriverWorkingTimeRuntimeProcessingPlan.INCLUDE_PARTITION_METADATA_PARAMETER, false,
DriverWorkingTimeRuntimeProcessingPlan.INCLUDE_PARTITION_MODULE_RESULTS_PARAMETER, false,
DriverWorkingTimeRuntimeProcessingPlan.INCLUDE_SUPPORT_EVIDENCE_NORMALIZATION_PARAMETER, false,
"includePartitionDebug", false
)
));
assertThat(result.moduleResults()).isEmpty();
assertThat(result.partitionResults()).containsOnlyKeys("12:DRIVER-1");
assertThat(result.partitionResults().get("12:DRIVER-1").metadata()).isEmpty();
assertThat(result.partitionResults().get("12:DRIVER-1").moduleResults()).isEmpty();
UnifiedRuntimeDerivedProjectionResultDto shapedResult =
(UnifiedRuntimeDerivedProjectionResultDto) result.partitionResults().get("12:DRIVER-1").result();
assertThat(shapedResult.supportEvidenceNormalization()).isNull();
assertThat(shapedResult.partitionDebug()).isNull();
}
private UnifiedRuntimeProcessingApiRequest sourceSelection() {
return new UnifiedRuntimeProcessingApiRequest(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
List.of(),
null,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
null,
null,
"12:DRIVER-1",
Set.of("12:DRIVER-1"),
false,
Set.of(),
false,
null,
null,
null,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-31T23:59:59Z"),
true,
15,
true,
null,
null,
null,
null,
List.of()
);
}
private UnifiedRuntimeProcessingRequest processedRequest() {
return new UnifiedRuntimeProcessingRequest(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
List.of(UUID.fromString("11111111-1111-1111-1111-111111111111")),
null,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
null,
null,
"12:DRIVER-1",
Set.of("12:DRIVER-1"),
false,
Set.of(),
false,
null,
null,
null,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-31T23:59:59Z"),
true,
15,
true
);
}
} }

View File

@ -38,9 +38,14 @@ class DriverWorkingTimeRuntimeEventProcessingProfileTest {
"minimumRestPeriodMinutes", "minimumRestPeriodMinutes",
"attachVehicleOnlyEvents", "attachVehicleOnlyEvents",
"vehicleEvidencePaddingMinutes", "vehicleEvidencePaddingMinutes",
"includeExecutionModuleResults",
"includePartitionMetadata",
"includePartitionModuleResults",
"includeActivityIntervals", "includeActivityIntervals",
"includeDrivingIntervals", "includeDrivingIntervals",
"includePartitionDebug" "includePartitionDebug",
"includeSupportEvidenceNormalization",
"eventMixingMode"
); );
} }
@ -155,6 +160,6 @@ class DriverWorkingTimeRuntimeEventProcessingProfileTest {
assertThat(result.partitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER); assertThat(result.partitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER);
assertThat(result.partitionResults()).containsOnlyKeys("12:DRIVER-1"); assertThat(result.partitionResults()).containsOnlyKeys("12:DRIVER-1");
assertThat(result.partitionResults().get("12:DRIVER-1").partitionType()).isEqualTo("DRIVER"); assertThat(result.partitionResults().get("12:DRIVER-1").partitionType()).isEqualTo("DRIVER");
assertThat(result.partitionResults().get("12:DRIVER-1").result()).isSameAs(driverResult); assertThat(result.partitionResults().get("12:DRIVER-1").result()).isEqualTo(driverResult);
} }
} }

View File

@ -0,0 +1,162 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class RuntimeVehicleUsageReconciliationServiceTest {
private final RuntimeVehicleUsageReconciliationService service = new RuntimeVehicleUsageReconciliationService();
@Test
void coalescesCardVehiclesUsedTechnicalMidnightSplitBeforeReconciliation() {
DriverWorkingTimeVehicleUsageInterval first = interval(
"TACHOGRAPH:CARD_VEHICLES_USED:1",
"CARD_VEHICLES_USED:first",
"CARD_VEHICLES_USED:first-end",
"2026-04-01T15:43:00Z",
"2026-04-01T23:59:59Z",
"DRIVER_CARD"
);
DriverWorkingTimeVehicleUsageInterval second = interval(
"TACHOGRAPH:CARD_VEHICLES_USED:2",
"CARD_VEHICLES_USED:second",
"CARD_VEHICLES_USED:second-end",
"2026-04-02T00:00:00Z",
"2026-04-02T10:30:00Z",
"DRIVER_CARD"
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(first, second));
assertThat(result.normalizedCardVehicleUsedIntervals()).hasSize(1);
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(1);
DriverWorkingTimeVehicleUsageInterval normalized = result.effectiveVehicleUsageIntervals().getFirst();
assertThat(normalized.startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T15:43:00Z"));
assertThat(normalized.endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-02T10:30:00Z"));
assertThat(normalized.sourceIntervalIds()).contains(
"TACHOGRAPH:CARD_VEHICLES_USED:1",
"TACHOGRAPH:CARD_VEHICLES_USED:2",
"CARD_VEHICLES_USED:first",
"CARD_VEHICLES_USED:second-end"
);
assertThat(result.vehicleUsageReconciliationDecisions()).extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.contains(RuntimeVehicleUsageReconciliationService.RULE_CVU_MIDNIGHT_CONTINUATION);
}
@Test
void reconcilesNormalizedCardVehiclesUsedWithIwCycleAndKeepsIwAsPrimary() {
DriverWorkingTimeVehicleUsageInterval cvuFirst = interval(
"TACHOGRAPH:CARD_VEHICLES_USED:1",
"CARD_VEHICLES_USED:first",
"CARD_VEHICLES_USED:first-end",
"2026-04-01T15:43:00Z",
"2026-04-01T23:59:59Z",
"DRIVER_CARD"
);
DriverWorkingTimeVehicleUsageInterval cvuSecond = interval(
"TACHOGRAPH:CARD_VEHICLES_USED:2",
"CARD_VEHICLES_USED:second",
"CARD_VEHICLES_USED:second-end",
"2026-04-02T00:00:00Z",
"2026-04-02T10:30:00Z",
"DRIVER_CARD"
);
DriverWorkingTimeVehicleUsageInterval iw = interval(
"TACHOGRAPH:IW_CYCLE:10",
"IW_CYCLE:first",
"IW_CYCLE:last",
"2026-04-01T15:43:00Z",
"2026-04-02T10:30:00Z",
"VEHICLE_UNIT"
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(cvuFirst, cvuSecond, iw));
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(1);
DriverWorkingTimeVehicleUsageInterval effective = result.effectiveVehicleUsageIntervals().getFirst();
assertThat(effective.intervalId()).isEqualTo("TACHOGRAPH:IW_CYCLE:10");
assertThat(effective.sourceKind()).isEqualTo("VEHICLE_UNIT");
assertThat(effective.sourceIntervalIds()).contains(
"TACHOGRAPH:IW_CYCLE:10",
"TACHOGRAPH:CARD_VEHICLES_USED:1",
"TACHOGRAPH:CARD_VEHICLES_USED:2"
);
assertThat(result.suppressedSecondaryIntervals()).hasSize(1);
assertThat(result.vehicleUsageReconciliationDecisions()).extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.contains(
RuntimeVehicleUsageReconciliationService.RULE_CVU_MIDNIGHT_CONTINUATION,
RuntimeVehicleUsageReconciliationService.RULE_CVU_IW_EXACT_OR_COMPATIBLE
);
}
@Test
void keepsCardVehiclesUsedAsFallbackWhenIwCycleIsMissing() {
DriverWorkingTimeVehicleUsageInterval card = interval(
"TACHOGRAPH:CARD_VEHICLES_USED:1",
"CARD_VEHICLES_USED:first",
"CARD_VEHICLES_USED:last",
"2026-04-01T08:00:00Z",
"2026-04-01T12:00:00Z",
"DRIVER_CARD"
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(card));
assertThat(result.effectiveVehicleUsageIntervals()).containsExactly(card);
assertThat(result.vehicleUsageReconciliationDecisions()).extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.containsExactly(RuntimeVehicleUsageReconciliationService.RULE_CVU_FALLBACK);
}
@Test
void keepsIwCycleAsPrimaryWhenCardVehiclesUsedIsMissing() {
DriverWorkingTimeVehicleUsageInterval iw = interval(
"TACHOGRAPH:IW_CYCLE:10",
"IW_CYCLE:first",
"IW_CYCLE:last",
"2026-04-01T08:00:00Z",
"2026-04-01T12:00:00Z",
"VEHICLE_UNIT"
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(iw));
assertThat(result.effectiveVehicleUsageIntervals()).containsExactly(iw);
assertThat(result.vehicleUsageReconciliationDecisions()).extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.containsExactly(RuntimeVehicleUsageReconciliationService.RULE_IW_PRIMARY);
}
private DriverWorkingTimeVehicleUsageInterval interval(
String intervalId,
String firstSourceIntervalId,
String lastSourceIntervalId,
String start,
String end,
String sourceKind
) {
OffsetDateTime startedAt = OffsetDateTime.parse(start);
OffsetDateTime endedAt = OffsetDateTime.parse(end);
return new DriverWorkingTimeVehicleUsageInterval(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
"1:driver",
intervalId,
firstSourceIntervalId,
lastSourceIntervalId,
startedAt,
endedAt,
startedAt.toEpochSecond(),
endedAt.toEpochSecond(),
endedAt.toEpochSecond() - startedAt.toEpochSecond(),
null,
null,
"1:LL-158TE",
"VIN:WDB9634031L123456",
sourceKind,
List.of(firstSourceIntervalId, lastSourceIntervalId)
);
}
}