Add runtime response shaping and vehicle usage reconciliation
This commit is contained in:
parent
46a89ea5b5
commit
829dc2e06a
126
README_PATCH.md
126
README_PATCH.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
|
||||||
|
|
||||||
|
public enum RuntimeVehicleUsageIntervalSourceType {
|
||||||
|
CARD_VEHICLES_USED,
|
||||||
|
IW_CYCLE,
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue