Reconcile CVU intervals across representations

This commit is contained in:
trifonovt 2026-06-16 17:04:45 +02:00
parent 1dd6d127a4
commit 34e6c6f236
14 changed files with 1537 additions and 35 deletions

View File

@ -0,0 +1,39 @@
# CVU boundary-censoring reconciliation fix
## Problem
The previous cross-representation CVU patch reduced the mixed result from 22 to 12 vehicle-usage intervals, but one duplicate pair remained.
The unmatched period was represented as:
- file session: `2026-03-31T00:00:00Z` to `2026-04-02T12:27:15Z`, missing begin odometer;
- database: `2026-04-01T00:00:00Z` to `2026-04-02T12:27:15Z`, complete database vehicle identity.
The file interval starts at the file-session load boundary and has no observed begin odometer. The database representation starts at the requested boundary. Before final projection clipping, their starts differ by 24 hours, so the prior 60-second boundary rule rejected them. After clipping, both appeared as the same interval in the REST response.
## Fix
Cross-representation CVU matching now accepts a one-sided boundary-censored match when:
- driver and vehicle identity are compatible;
- the intervals overlap;
- the opposite boundary matches within 60 seconds; and
- the non-aligned boundary has a missing corresponding odometer in at least one representation.
Known, conflicting boundaries are still kept separate.
Fusion keeps the database interval identity and vehicle/VIN data, but preserves the wider temporal boundary. It does not copy an odometer value onto an earlier/later boundary where that value was not actually observed.
New decision types:
- `LEFT_BOUNDARY_CENSORED`
- `RIGHT_BOUNDARY_CENSORED`
## Regression coverage
Added tests for:
- the supplied March 31 / April 1 left-boundary case;
- preserving a `null` begin odometer on the widened interval;
- retaining database identity and file-session provenance;
- not reconciling intervals whose differing starts are both explicitly observed.

View File

@ -0,0 +1,30 @@
# CVU final merge safeguard
## Problem
After cross-representation CVU reconciliation the supplied mixed execution still contained 12 effective vehicle-usage intervals instead of 11. The remaining pair represented the same usage period:
- file-session CVU: `2026-03-31T00:00:00Z` to `2026-04-02T12:27:15Z`, registration present, VIN missing;
- database CVU: `2026-04-01T00:00:00Z` to `2026-04-02T12:27:15Z`, same registration, VIN present.
The final `DriverVehicleUsageMergeModule` required exact `vehicleKey` equality, so `null` and the database VIN prevented coalescing even though registration and overlap proved compatible identity.
## Fix
`DriverVehicleUsageMergeModule` now:
- accepts complementary vehicle identity when registration or VIN matches and the other field is missing;
- merges overlapping as well as immediately adjacent effective intervals;
- rejects explicit registration/VIN conflicts;
- rejects materially conflicting odometers at identical boundaries;
- selects the richer interval as identity primary;
- preserves the earliest start, latest end, correct boundary odometers, and complete source provenance;
- reports `coalescedIntervalCount` metadata.
This is a generic final-interval safeguard and does not depend on tachograph representation enums.
## Expected supplied-data result
- vehicle usage intervals: `12 -> 11`
- VU card-absent intervals: remains `10`
- support geo events: remains `141`

71
README_CVU_PATCH.md Normal file
View File

@ -0,0 +1,71 @@
# Patch: Cross-representation CVU interval reconciliation
## Problem
A request containing both `TACHOGRAPH_FILE_SESSION` and `TACHOGRAPH_DB` produced 22 final vehicle-usage intervals instead of 11.
The old sequence globally sorted all `CARD_VEHICLES_USED` intervals before technical-midnight normalization. A complete database interval could therefore appear between two file-session fragments:
```text
file fragment ending 23:59:59
complete database interval
file continuation starting 00:00:00
```
The database interval interrupted adjacency, so the file fragments were not coalesced. The remaining file fragment and complete database interval both survived.
## Fix
The reconciliation order is now:
1. Classify reconstructed intervals by source type and physical representation.
2. Normalize CVU technical-midnight fragments independently for `DATABASE`, `FILE_SESSION`, and `UNKNOWN` representations.
3. Reconcile equivalent database and file-session CVU intervals.
- exact/compatible boundaries use the existing 60-second tolerance;
- driver and vehicle identity must be compatible;
- non-null odometer boundaries may differ by at most 1 km;
- the database interval is retained as primary;
- file-session source IDs are attached as provenance.
4. Reconcile the representation-neutral effective CVU intervals with `IW_CYCLE`.
5. Continue with the existing final vehicle-usage merge.
## Diagnostics
`RuntimeVehicleUsageReconciliationResult` now exposes:
```text
representationReconciledCardVehicleUsedIntervals
```
The module metadata includes:
```text
representationReconciledCardVehicleUsedIntervalCount
```
A new decision rule identifies suppressed cross-representation duplicates:
```text
tachograph.vehicle-usage.card-vehicles-used.cross-representation-equivalent
```
## Regression coverage
Tests cover:
- the production ordering where a DB interval interrupts two file fragments;
- DB-primary fusion with file-session provenance;
- preservation of intervals when odometer boundaries meaningfully conflict;
- existing CVU midnight normalization and CVU/IW reconciliation behavior.
A Java 21 harness using all 11 periods from the supplied result set produced:
```text
raw intervals: 51
representation-normalized CVU: 22
representation-reconciled CVU: 11
final effective vehicle usage: 11
suppressed cross-representation CVU: 11
```
Maven was not available in the execution environment, so the complete project suite was not run. The changed main classes and regression test were compiled with Java 21 dependency stubs, and the interval-level harness executed successfully.

View File

@ -0,0 +1,186 @@
# Fahrer Home / NotHome Klassifikation + Trip-Segmentierung — Business Rules
```
# ════════════════════════════════════════════════════════════
# Quelle: Tachograph M_ (Vehicle Unit, GNSS-Positionen)
# + C_ (Fahrerkarte, Aktivitäten / Steckung+Entnahme)
# ════════════════════════════════════════════════════════════
# ───────────────────── Konstanten ─────────────────────
CONST NDI_LONG = 7.5h # Schwelle "langes" NDI
CONST NDI_VERY_LONG = 24h
CONST CARD_REMOVAL_PCT = 0.80 # 80% der NDI-Dauer
CONST VISIT_SHARE = 0.25 # 25% aller (NDI > 7.5h)
CONST DBSCAN_EPS = 150m # geo-Metrik (Haversine / PostGIS)
CONST DBSCAN_MIN_PTS = 3
ENUM HomeStatus { HOME, NOT_HOME }
# ───────────────────── Datenmodell ─────────────────────
STRUCT DI: # Lenkintervall
driverId
vehicleId
start; end
posStart; posEnd # (x,y) aus M_, nullable
gnssTrace[] # (ts, x, y) Stützpunkte aus M_
STRUCT NDI: # Nicht-Lenkintervall (Ruhe)
driverId
vehicleStart; vehicleEnd # Fahrzeug der vorigen / nächsten DI
start; end # = vorige.end / nächste.start
pos = null # zugeordnete (x,y), nullable
cardOut = null # Intervall [Entnahme, Steckung], nullable
cluster = null # DBSCAN-Cluster-Id (inkl. NOISE)
status = null # HOME | NOT_HOME
FUNCTION dur(iv) = iv.end - iv.start
# ═══════════ 1. NDI aus aufeinanderfolgenden DI ableiten ═══════════
FUNCTION buildNDIs(driverId, dis): # dis chronologisch sortiert
out = []
FOR i IN 1 .. len(dis)-1:
prev = dis[i-1]; next = dis[i]
out += NDI {
driverId : driverId
vehicleStart : prev.vehicleId
vehicleEnd : next.vehicleId
start : prev.end
end : next.start
pos : assignPos(prev, next)
cardOut : cardRemovalInterval(prev.end, next.start) # aus C_ Events
}
RETURN out
# Fahrzeug steht während NDI -> letzte bekannte Position zuordnen
FUNCTION assignPos(prev, next):
RETURN prev.posEnd ?? next.posStart # nullable
# ═══════════ 2. LocationStatistik + DBSCAN ═══════════
# nur NDI > 7.5h mit bekannter Position fließen in die Statistik
FUNCTION clusterLongNDIs(allNDIs):
longs = [n IN allNDIs WHERE dur(n) > NDI_LONG AND n.pos != null]
labels = DBSCAN(points = [n.pos FOR n IN longs],
eps = DBSCAN_EPS, minPts = DBSCAN_MIN_PTS, metric = HAVERSINE)
FOR n, lbl IN zip(longs, labels):
n.cluster = lbl
RETURN longs # inkl. NOISE-Label
# ═══════════ 3. Home-Locations bestimmen ═══════════
FUNCTION determineHomeLocations(longs):
# 3a Firmen-Home (Depots): Cluster mit > 25% ALLER langen NDI
totalCompany = count(longs) # Nenner = alle langen NDI
companyHome = { c FOR (c, visits) IN groupByCluster(longs)
WHERE c != NOISE
AND count(visits) / totalCompany > VISIT_SHARE }
# 3b Fahrer-Home (privat): pro Fahrer Cluster mit > 25% SEINER langen NDI,
# außerhalb der Schnittmenge mit den Firmen-Clustern
driverHome = {} # driverId -> Set<cluster>
FOR (driverId, dnNDIs) IN groupByDriver(longs):
totalDriver = count(dnNDIs)
FOR (c, visits) IN groupByCluster(dnNDIs):
IF c != NOISE
AND count(visits) / totalDriver > VISIT_SHARE
AND c NOT IN companyHome: # Schnittmenge mit Firma entfernen
driverHome[driverId] += c
RETURN (companyHome, driverHome)
# ═══════════ 4. Klassifikation Home / NotHome ═══════════
FUNCTION classify(n, companyHome, driverHome):
# A Karte am NDI-Ende in anderem Fahrzeug gesteckt
IF n.vehicleStart != n.vehicleEnd: RETURN HOME
# B Karte > 80% des NDI entnommen
IF n.cardOut != null
AND dur(n.cardOut) > CARD_REMOVAL_PCT * dur(n): RETURN HOME
# C Ruhe > 24h
IF dur(n) > NDI_VERY_LONG: RETURN HOME
# D keine Position bekannt
IF n.pos == null:
RETURN (dur(n) > NDI_LONG) ? HOME : NOT_HOME
# E Position bekannt + langes NDI -> Entscheidung über Home-Location-Cluster
IF dur(n) > NDI_LONG:
IF n.cluster IN companyHome
OR n.cluster IN driverHome[n.driverId]:
RETURN HOME # Depot / privates Zuhause
ELSE:
RETURN NOT_HOME # Nächtigung im Fahrzeug
# kurze Ruhe beim Fahrzeug
RETURN NOT_HOME
# ═══════════ 5. Grenzübertritte -> TripSegmente ═══════════
STRUCT TripSegment:
driverId; vehicleId
start; end
countryFrom; countryTo
posFrom; posTo
# BorderCrossing = explizites Tacho-Event (Smart Tacho v2)
# ODER Länderwechsel im reverse-geocodeten GNSS-Trace
FUNCTION buildTripSegments(driverId, dis): # chronologisch
segs = []
segStart = dis[0].start
posFrom = dis[0].posStart
country = countryOf(dis[0].posStart) # PostGIS / Nominatim
FOR di IN dis:
FOR p IN di.gnssTrace: # (ts, x, y) aus M_
c = countryOf(p)
IF c != country: # -> Grenzübertritt
segs += TripSegment {
driverId : driverId
vehicleId : di.vehicleId
start : segStart
end : p.ts
countryFrom : country
countryTo : c
posFrom : posFrom
posTo : p
}
segStart = p.ts; posFrom = p; country = c
# Schluss-Segment (letzter Abschnitt ohne weiteren Übertritt)
segs += TripSegment {
driverId : driverId
vehicleId : dis[last].vehicleId
start : segStart
end : dis[last].end
countryFrom : country
countryTo : country
posFrom : posFrom
posTo : dis[last].posEnd
}
RETURN segs
# ═══════════ Orchestrierung ═══════════
FUNCTION run(files_M, files_C):
acts = parseTacho(files_M, files_C) # DI + Karten-Events
allNDIs = []
segments = []
FOR (driverId, dis) IN groupDIsByDriver(acts):
dis = sortByTime(dis)
allNDIs += buildNDIs(driverId, dis)
segments += buildTripSegments(driverId, dis)
# zwei Pässe: erst clustern, dann klassifizieren
longs = clusterLongNDIs(allNDIs)
(companyHome, driverHome) = determineHomeLocations(longs)
FOR n IN allNDIs:
n.status = classify(n, companyHome, driverHome)
RETURN (allNDIs, segments)
```

View File

@ -0,0 +1,187 @@
# Driver Home / NotHome Classification + Trip Segmentation — Business Rules
```text
# ════════════════════════════════════════════════════════════
# Source: Tachograph M_ (Vehicle Unit, GNSS positions)
# + C_ (Driver Card, activities / insertion + removal)
# ════════════════════════════════════════════════════════════
# ───────────────────── Constants ─────────────────────
CONST NDI_LONG = 7.5h # Threshold for a "long" NDI
CONST NDI_VERY_LONG = 24h
CONST CARD_REMOVAL_PCT = 0.80 # 80% of the NDI duration
CONST VISIT_SHARE = 0.25 # 25% of all (NDI > 7.5h)
CONST DBSCAN_EPS = 150m # Geographic metric (Haversine / PostGIS)
CONST DBSCAN_MIN_PTS = 3
ENUM HomeStatus { HOME, NOT_HOME }
# ───────────────────── Data Model ─────────────────────
STRUCT DI: # Driving interval
driverId
vehicleId
start; end
posStart; posEnd # (x,y) from M_, nullable
gnssTrace[] # (ts, x, y) supporting points from M_
STRUCT NDI: # Non-driving interval (rest)
driverId
vehicleStart; vehicleEnd # Vehicle of the previous / next DI
start; end # = previous.end / next.start
pos = null # Assigned (x,y), nullable
cardOut = null # Interval [removal, insertion], nullable
cluster = null # DBSCAN cluster ID (including NOISE)
status = null # HOME | NOT_HOME
FUNCTION dur(iv) = iv.end - iv.start
# ═══════════ 1. Derive NDIs from consecutive DIs ═══════════
FUNCTION buildNDIs(driverId, dis): # dis sorted chronologically
out = []
FOR i IN 1 .. len(dis)-1:
prev = dis[i-1]; next = dis[i]
out += NDI {
driverId : driverId
vehicleStart : prev.vehicleId
vehicleEnd : next.vehicleId
start : prev.end
end : next.start
pos : assignPos(prev, next)
cardOut : cardRemovalInterval(prev.end, next.start) # from C_ events
}
RETURN out
# The vehicle is stationary during the NDI -> assign the last known position
FUNCTION assignPos(prev, next):
RETURN prev.posEnd ?? next.posStart # nullable
# ═══════════ 2. Location Statistics + DBSCAN ═══════════
# Only NDIs > 7.5h with a known position are included in the statistics
FUNCTION clusterLongNDIs(allNDIs):
longs = [n IN allNDIs WHERE dur(n) > NDI_LONG AND n.pos != null]
labels = DBSCAN(points = [n.pos FOR n IN longs],
eps = DBSCAN_EPS, minPts = DBSCAN_MIN_PTS, metric = HAVERSINE)
FOR n, lbl IN zip(longs, labels):
n.cluster = lbl
RETURN longs # including the NOISE label
# ═══════════ 3. Determine Home Locations ═══════════
FUNCTION determineHomeLocations(longs):
# 3a Company home locations (depots): clusters containing > 25% of ALL long NDIs
totalCompany = count(longs) # Denominator = all long NDIs
companyHome = { c FOR (c, visits) IN groupByCluster(longs)
WHERE c != NOISE
AND count(visits) / totalCompany > VISIT_SHARE }
# 3b Driver home locations (private): for each driver, clusters containing
# > 25% of HIS/HER long NDIs, excluding clusters that overlap
# with the company clusters
driverHome = {} # driverId -> Set<cluster>
FOR (driverId, dnNDIs) IN groupByDriver(longs):
totalDriver = count(dnNDIs)
FOR (c, visits) IN groupByCluster(dnNDIs):
IF c != NOISE
AND count(visits) / totalDriver > VISIT_SHARE
AND c NOT IN companyHome: # Remove overlap with company locations
driverHome[driverId] += c
RETURN (companyHome, driverHome)
# ═══════════ 4. Home / NotHome Classification ═══════════
FUNCTION classify(n, companyHome, driverHome):
# A Card inserted into another vehicle at the end of the NDI
IF n.vehicleStart != n.vehicleEnd: RETURN HOME
# B Card removed for > 80% of the NDI
IF n.cardOut != null
AND dur(n.cardOut) > CARD_REMOVAL_PCT * dur(n): RETURN HOME
# C Rest > 24h
IF dur(n) > NDI_VERY_LONG: RETURN HOME
# D No position known
IF n.pos == null:
RETURN (dur(n) > NDI_LONG) ? HOME : NOT_HOME
# E Position known + long NDI -> decide using home-location clusters
IF dur(n) > NDI_LONG:
IF n.cluster IN companyHome
OR n.cluster IN driverHome[n.driverId]:
RETURN HOME # Depot / private home
ELSE:
RETURN NOT_HOME # Overnight stay in the vehicle
# Short rest near/at the vehicle
RETURN NOT_HOME
# ═══════════ 5. Border Crossings -> Trip Segments ═══════════
STRUCT TripSegment:
driverId; vehicleId
start; end
countryFrom; countryTo
posFrom; posTo
# BorderCrossing = explicit tachograph event (Smart Tachograph v2)
# OR country change in the reverse-geocoded GNSS trace
FUNCTION buildTripSegments(driverId, dis): # chronological
segs = []
segStart = dis[0].start
posFrom = dis[0].posStart
country = countryOf(dis[0].posStart) # PostGIS / Nominatim
FOR di IN dis:
FOR p IN di.gnssTrace: # (ts, x, y) from M_
c = countryOf(p)
IF c != country: # -> Border crossing
segs += TripSegment {
driverId : driverId
vehicleId : di.vehicleId
start : segStart
end : p.ts
countryFrom : country
countryTo : c
posFrom : posFrom
posTo : p
}
segStart = p.ts; posFrom = p; country = c
# Final segment (last section without another crossing)
segs += TripSegment {
driverId : driverId
vehicleId : dis[last].vehicleId
start : segStart
end : dis[last].end
countryFrom : country
countryTo : country
posFrom : posFrom
posTo : dis[last].posEnd
}
RETURN segs
# ═══════════ Orchestration ═══════════
FUNCTION run(files_M, files_C):
acts = parseTacho(files_M, files_C) # DIs + card events
allNDIs = []
segments = []
FOR (driverId, dis) IN groupDIsByDriver(acts):
dis = sortByTime(dis)
allNDIs += buildNDIs(driverId, dis)
segments += buildTripSegments(driverId, dis)
# Two passes: first cluster, then classify
longs = clusterLongNDIs(allNDIs)
(companyHome, driverHome) = determineHomeLocations(longs)
FOR n IN allNDIs:
n.status = classify(n, companyHome, driverHome)
RETURN (allNDIs, segments)
```

View File

@ -12,6 +12,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
@ -27,7 +28,7 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Vehicle usage merge",
"Merges adjacent or continuous effective same-driver/same-vehicle usage intervals after source reconciliation.",
"Merges adjacent or overlapping effective same-driver/same-vehicle usage intervals after source reconciliation, including complementary representations where one side lacks VIN or another identity field.",
"JAVA",
Set.of(DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION),
Set.of("RuntimeVehicleUsageReconciliationResult.effectiveVehicleUsageIntervals"),
@ -43,6 +44,7 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("inputIntervalCount", intervals.size());
metadata.put("mergedIntervalCount", merged.size());
metadata.put("coalescedIntervalCount", Math.max(0, intervals.size() - merged.size()));
return new RuntimeProcessingModuleResult(
moduleKey(),
RuntimeProcessingModuleStatus.SUCCESS,
@ -100,42 +102,248 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
if (left == null || right == null || left.endedAt() == null || right.startedAt() == null) {
if (left == null || right == null
|| left.startedAt() == null || left.endedAt() == null
|| right.startedAt() == null || right.endedAt() == null) {
return false;
}
return Objects.equals(left.driverKey(), right.driverKey())
&& Objects.equals(left.registrationKey(), right.registrationKey())
&& Objects.equals(left.vehicleKey(), right.vehicleKey())
&& compatibleVehicleIdentity(left, right)
&& compatibleAlignedBoundaryOdometers(left, right)
&& !right.startedAt().isAfter(left.endedAt().plusSeconds(1));
}
private boolean compatibleVehicleIdentity(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
boolean registrationCompatible = compatibleNullable(left.registrationKey(), right.registrationKey());
boolean vehicleCompatible = compatibleNullable(left.vehicleKey(), right.vehicleKey());
if (!registrationCompatible || !vehicleCompatible) {
return false;
}
boolean sameRegistration = hasText(left.registrationKey())
&& hasText(right.registrationKey())
&& Objects.equals(left.registrationKey(), right.registrationKey());
boolean sameVehicle = hasText(left.vehicleKey())
&& hasText(right.vehicleKey())
&& Objects.equals(left.vehicleKey(), right.vehicleKey());
boolean bothUnidentified = !hasVehicleIdentity(left) && !hasVehicleIdentity(right);
return sameRegistration || sameVehicle || bothUnidentified;
}
private boolean compatibleAlignedBoundaryOdometers(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
if (Objects.equals(left.startedAt(), right.startedAt())
&& materiallyDifferent(left.odometerBeginKm(), right.odometerBeginKm())) {
return false;
}
return !Objects.equals(left.endedAt(), right.endedAt())
|| !materiallyDifferent(left.odometerEndKm(), right.odometerEndKm());
}
private boolean materiallyDifferent(Long left, Long right) {
return left != null && right != null && Math.abs(left - right) > 1L;
}
private boolean compatibleNullable(String left, String right) {
return !hasText(left) || !hasText(right) || Objects.equals(left, right);
}
private boolean hasVehicleIdentity(DriverWorkingTimeVehicleUsageInterval interval) {
return interval != null && (hasText(interval.registrationKey()) || hasText(interval.vehicleKey()));
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private DriverWorkingTimeVehicleUsageInterval merge(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
LinkedHashSet<String> sourceIntervalIds = new LinkedHashSet<>(left.sourceIntervalIds());
sourceIntervalIds.addAll(right.sourceIntervalIds());
OffsetDateTime mergedEnd = left.endedAt();
if (right.endedAt() != null && (mergedEnd == null || right.endedAt().isAfter(mergedEnd))) {
mergedEnd = right.endedAt();
}
DriverWorkingTimeVehicleUsageInterval primary = richer(left, right);
DriverWorkingTimeVehicleUsageInterval secondary = primary == left ? right : left;
OffsetDateTime mergedStart = earliest(left.startedAt(), right.startedAt());
OffsetDateTime mergedEnd = latest(left.endedAt(), right.endedAt());
LinkedHashSet<String> sourceIntervalIds = new LinkedHashSet<>();
addSourceIds(sourceIntervalIds, left);
addSourceIds(sourceIntervalIds, right);
return new DriverWorkingTimeVehicleUsageInterval(
left.sessionId(),
left.driverKey(),
left.intervalId(),
left.firstSourceIntervalId(),
right.lastSourceIntervalId() == null ? left.lastSourceIntervalId() : right.lastSourceIntervalId(),
left.startedAt(),
firstNonNull(primary.sessionId(), secondary.sessionId()),
firstNonBlank(primary.driverKey(), secondary.driverKey()),
firstNonBlank(primary.intervalId(), secondary.intervalId()),
firstSourceIntervalId(left, right, mergedStart),
lastSourceIntervalId(left, right, mergedEnd),
mergedStart,
mergedEnd,
left.startedAtEpochSecond(),
mergedEnd == null ? null : mergedEnd.toEpochSecond(),
mergedEnd == null ? left.durationSeconds() : mergedEnd.toEpochSecond() - left.startedAtEpochSecond(),
left.odometerBeginKm(),
right.odometerEndKm() == null ? left.odometerEndKm() : right.odometerEndKm(),
left.registrationKey(),
left.vehicleKey(),
left.sourceKind(),
mergedStart.toEpochSecond(),
mergedEnd.toEpochSecond(),
mergedEnd.toEpochSecond() - mergedStart.toEpochSecond(),
odometerAtStart(left, right, mergedStart, primary),
odometerAtEnd(left, right, mergedEnd, primary),
firstNonBlank(primary.registrationKey(), secondary.registrationKey()),
firstNonBlank(primary.vehicleKey(), secondary.vehicleKey()),
firstNonBlank(primary.sourceKind(), secondary.sourceKind()),
List.copyOf(sourceIntervalIds)
);
}
private DriverWorkingTimeVehicleUsageInterval richer(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
int leftScore = richnessScore(left);
int rightScore = richnessScore(right);
if (rightScore > leftScore) {
return right;
}
if (leftScore > rightScore) {
return left;
}
return preferStableSession(right.sessionId(), left.sessionId()) ? right : left;
}
private int richnessScore(DriverWorkingTimeVehicleUsageInterval interval) {
if (interval == null) {
return Integer.MIN_VALUE;
}
int score = 0;
if (hasText(interval.vehicleKey())) {
score += 8;
}
if (hasText(interval.registrationKey())) {
score += 4;
}
if (interval.odometerBeginKm() != null) {
score += 2;
}
if (interval.odometerEndKm() != null) {
score += 2;
}
if (hasText(interval.sourceKind())) {
score += 1;
}
return score;
}
private boolean preferStableSession(UUID candidate, UUID current) {
if (candidate == null) {
return false;
}
if (current == null) {
return true;
}
return isZero(candidate) && !isZero(current);
}
private boolean isZero(UUID value) {
return value != null && value.getMostSignificantBits() == 0L && value.getLeastSignificantBits() == 0L;
}
private String firstSourceIntervalId(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right,
OffsetDateTime mergedStart
) {
DriverWorkingTimeVehicleUsageInterval owner = Objects.equals(left.startedAt(), right.startedAt())
? richer(left, right)
: (Objects.equals(left.startedAt(), mergedStart) ? left : right);
DriverWorkingTimeVehicleUsageInterval other = owner == left ? right : left;
return firstNonBlank(owner.firstSourceIntervalId(), owner.intervalId(), other.firstSourceIntervalId(), other.intervalId());
}
private String lastSourceIntervalId(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right,
OffsetDateTime mergedEnd
) {
DriverWorkingTimeVehicleUsageInterval owner = Objects.equals(left.endedAt(), right.endedAt())
? richer(left, right)
: (Objects.equals(left.endedAt(), mergedEnd) ? left : right);
DriverWorkingTimeVehicleUsageInterval other = owner == left ? right : left;
return firstNonBlank(owner.lastSourceIntervalId(), owner.intervalId(), other.lastSourceIntervalId(), other.intervalId());
}
private Long odometerAtStart(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right,
OffsetDateTime mergedStart,
DriverWorkingTimeVehicleUsageInterval primary
) {
if (Objects.equals(left.startedAt(), right.startedAt())) {
DriverWorkingTimeVehicleUsageInterval secondary = primary == left ? right : left;
return firstNonNull(primary.odometerBeginKm(), secondary.odometerBeginKm());
}
return Objects.equals(left.startedAt(), mergedStart) ? left.odometerBeginKm() : right.odometerBeginKm();
}
private Long odometerAtEnd(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right,
OffsetDateTime mergedEnd,
DriverWorkingTimeVehicleUsageInterval primary
) {
if (Objects.equals(left.endedAt(), right.endedAt())) {
DriverWorkingTimeVehicleUsageInterval secondary = primary == left ? right : left;
return firstNonNull(primary.odometerEndKm(), secondary.odometerEndKm());
}
return Objects.equals(left.endedAt(), mergedEnd) ? left.odometerEndKm() : right.odometerEndKm();
}
private OffsetDateTime earliest(OffsetDateTime left, OffsetDateTime right) {
return left.isBefore(right) ? left : right;
}
private OffsetDateTime latest(OffsetDateTime left, OffsetDateTime right) {
return left.isAfter(right) ? left : right;
}
private void addSourceIds(LinkedHashSet<String> ids, DriverWorkingTimeVehicleUsageInterval interval) {
if (interval == null) {
return;
}
add(ids, interval.intervalId());
add(ids, interval.firstSourceIntervalId());
add(ids, interval.lastSourceIntervalId());
if (interval.sourceIntervalIds() != null) {
interval.sourceIntervalIds().forEach(value -> add(ids, value));
}
}
private void add(LinkedHashSet<String> values, String value) {
if (hasText(value)) {
values.add(value);
}
}
@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 (hasText(value)) {
return value;
}
}
return null;
}
}

View File

@ -36,7 +36,7 @@ public class VehicleUsageReconciliationModule implements RuntimeProcessingModule
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.",
"Normalizes CARD_VEHICLES_USED technical midnight splits independently per physical representation, reconciles equivalent database/file-session CVU intervals, and then reconciles effective CVU intervals with IW_CYCLE.",
"JAVA",
Set.of(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS),
Set.of("DriverVehicleUsageIntervalEvent"),
@ -45,14 +45,19 @@ public class VehicleUsageReconciliationModule implements RuntimeProcessingModule
}
@Override
public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) {
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("representationReconciledCardVehicleUsedIntervalCount", result.representationReconciledCardVehicleUsedIntervals().size());
metadata.put("effectiveVehicleUsageIntervalCount", result.effectiveVehicleUsageIntervals().size());
metadata.put("suppressedSecondaryIntervalCount", result.suppressedSecondaryIntervals().size());
metadata.put("crossRepresentationSuppressedCvuIntervalCount", result.vehicleUsageReconciliationDecisions().stream()
.filter(decision -> RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT.equals(decision.ruleId()))
.mapToInt(decision -> decision.secondaryIntervalIds().size())
.sum());
metadata.put("vehicleUsageReconciliationDecisionCount", result.vehicleUsageReconciliationDecisions().size());
metadata.put("vehicleUsageReconciliationDecisions", result.vehicleUsageReconciliationDecisions());
metadata.put("notes", result.notes());

View File

@ -6,6 +6,7 @@ import java.time.OffsetDateTime;
public record RuntimeVehicleUsageIntervalDescriptor(
DriverWorkingTimeVehicleUsageInterval interval,
RuntimeVehicleUsageIntervalSourceType sourceType,
RuntimeVehicleUsageIntervalRepresentation representation,
String sourceKind,
String driverKey,
String registrationKey,

View File

@ -4,11 +4,14 @@ import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVe
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
public class RuntimeVehicleUsageIntervalDescriptorFactory {
private static final UUID ZERO_UUID = new UUID(0L, 0L);
public RuntimeVehicleUsageIntervalDescriptor describe(DriverWorkingTimeVehicleUsageInterval interval) {
if (interval == null) {
return null;
@ -16,6 +19,7 @@ public class RuntimeVehicleUsageIntervalDescriptorFactory {
return new RuntimeVehicleUsageIntervalDescriptor(
interval,
sourceType(interval),
representation(interval),
interval.sourceKind(),
interval.driverKey(),
interval.registrationKey(),
@ -41,6 +45,24 @@ public class RuntimeVehicleUsageIntervalDescriptorFactory {
return RuntimeVehicleUsageIntervalSourceType.OTHER;
}
private RuntimeVehicleUsageIntervalRepresentation representation(DriverWorkingTimeVehicleUsageInterval interval) {
UUID sessionId = interval.sessionId();
if (sessionId != null) {
return ZERO_UUID.equals(sessionId)
? RuntimeVehicleUsageIntervalRepresentation.DATABASE
: RuntimeVehicleUsageIntervalRepresentation.FILE_SESSION;
}
List<String> identifiers = identifiers(interval).stream().map(this::normalize).toList();
if (identifiers.stream().anyMatch(value -> value.startsWith("TACHOGRAPH:"))) {
return RuntimeVehicleUsageIntervalRepresentation.DATABASE;
}
if (identifiers.stream().anyMatch(value -> value.startsWith("CVU-") || value.startsWith("VUIW-"))) {
return RuntimeVehicleUsageIntervalRepresentation.FILE_SESSION;
}
return RuntimeVehicleUsageIntervalRepresentation.UNKNOWN;
}
private List<String> identifiers(DriverWorkingTimeVehicleUsageInterval interval) {
List<String> result = new ArrayList<>();
add(result, interval.intervalId());

View File

@ -0,0 +1,8 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
/** Physical representation from which a reconstructed vehicle-usage interval originated. */
public enum RuntimeVehicleUsageIntervalRepresentation {
DATABASE,
FILE_SESSION,
UNKNOWN
}

View File

@ -6,6 +6,7 @@ import java.util.List;
public record RuntimeVehicleUsageReconciliationResult(
List<DriverWorkingTimeVehicleUsageInterval> rawVehicleUsageIntervals,
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardVehicleUsedIntervals,
List<DriverWorkingTimeVehicleUsageInterval> representationReconciledCardVehicleUsedIntervals,
List<DriverWorkingTimeVehicleUsageInterval> effectiveVehicleUsageIntervals,
List<DriverWorkingTimeVehicleUsageInterval> suppressedSecondaryIntervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> vehicleUsageReconciliationDecisions,
@ -15,6 +16,9 @@ public record RuntimeVehicleUsageReconciliationResult(
public RuntimeVehicleUsageReconciliationResult {
rawVehicleUsageIntervals = rawVehicleUsageIntervals == null ? List.of() : List.copyOf(rawVehicleUsageIntervals);
normalizedCardVehicleUsedIntervals = normalizedCardVehicleUsedIntervals == null ? List.of() : List.copyOf(normalizedCardVehicleUsedIntervals);
representationReconciledCardVehicleUsedIntervals = representationReconciledCardVehicleUsedIntervals == null
? List.of()
: List.copyOf(representationReconciledCardVehicleUsedIntervals);
effectiveVehicleUsageIntervals = effectiveVehicleUsageIntervals == null ? List.of() : List.copyOf(effectiveVehicleUsageIntervals);
suppressedSecondaryIntervals = suppressedSecondaryIntervals == null ? List.of() : List.copyOf(suppressedSecondaryIntervals);
vehicleUsageReconciliationDecisions = vehicleUsageReconciliationDecisions == null ? List.of() : List.copyOf(vehicleUsageReconciliationDecisions);

View File

@ -5,6 +5,7 @@ import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@ -18,6 +19,8 @@ import org.springframework.stereotype.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_CROSS_REPRESENTATION_EQUIVALENT =
"tachograph.vehicle-usage.card-vehicles-used.cross-representation-equivalent";
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";
@ -57,28 +60,71 @@ public class RuntimeVehicleUsageReconciliationService {
}
}
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardIntervals = normalizeCardVehicleUsed(cardIntervals, decisions);
ReconciliationStepResult reconciled = reconcileCardWithIw(normalizedCardIntervals, iwIntervals, decisions, warnings);
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardIntervals =
normalizeCardVehicleUsedByRepresentation(cardIntervals, decisions);
CrossRepresentationStepResult representationReconciled =
reconcileCardRepresentations(normalizedCardIntervals, decisions, warnings);
ReconciliationStepResult reconciled = reconcileCardWithIw(
representationReconciled.effectiveIntervals(),
iwIntervals,
decisions,
warnings
);
List<DriverWorkingTimeVehicleUsageInterval> effective = new ArrayList<>();
effective.addAll(reconciled.effectiveIntervals());
effective.addAll(otherIntervals);
effective.sort(intervalComparator());
List<DriverWorkingTimeVehicleUsageInterval> suppressed = new ArrayList<>();
suppressed.addAll(representationReconciled.suppressedIntervals());
suppressed.addAll(reconciled.suppressedIntervals());
suppressed.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."
"Vehicle usage reconciliation coalesced CARD_VEHICLES_USED technical midnight splits independently per physical representation, reconciled exact and one-sided boundary-censored database/file-session CVU intervals, and only then reconciled effective CVU intervals with IW_CYCLE intervals."
);
return new RuntimeVehicleUsageReconciliationResult(
raw,
normalizedCardIntervals,
representationReconciled.effectiveIntervals(),
effective,
reconciled.suppressedIntervals(),
suppressed,
decisions,
notes,
warnings
);
}
private List<DriverWorkingTimeVehicleUsageInterval> normalizeCardVehicleUsedByRepresentation(
List<DriverWorkingTimeVehicleUsageInterval> intervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
Map<RuntimeVehicleUsageIntervalRepresentation, List<DriverWorkingTimeVehicleUsageInterval>> byRepresentation =
new EnumMap<>(RuntimeVehicleUsageIntervalRepresentation.class);
for (DriverWorkingTimeVehicleUsageInterval interval : intervals) {
RuntimeVehicleUsageIntervalDescriptor descriptor = descriptorFactory.describe(interval);
RuntimeVehicleUsageIntervalRepresentation representation = descriptor == null
? RuntimeVehicleUsageIntervalRepresentation.UNKNOWN
: descriptor.representation();
byRepresentation.computeIfAbsent(representation, ignored -> new ArrayList<>()).add(interval);
}
List<DriverWorkingTimeVehicleUsageInterval> normalized = new ArrayList<>();
for (RuntimeVehicleUsageIntervalRepresentation representation : RuntimeVehicleUsageIntervalRepresentation.values()) {
List<DriverWorkingTimeVehicleUsageInterval> representationIntervals = byRepresentation.get(representation);
if (representationIntervals == null || representationIntervals.isEmpty()) {
continue;
}
normalized.addAll(normalizeCardVehicleUsed(representationIntervals, decisions));
}
normalized.sort(intervalComparator());
return List.copyOf(normalized);
}
private List<DriverWorkingTimeVehicleUsageInterval> normalizeCardVehicleUsed(
List<DriverWorkingTimeVehicleUsageInterval> intervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions
@ -122,6 +168,250 @@ public class RuntimeVehicleUsageReconciliationService {
return List.copyOf(normalized);
}
private CrossRepresentationStepResult reconcileCardRepresentations(
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardIntervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions,
List<String> warnings
) {
if (normalizedCardIntervals == null || normalizedCardIntervals.isEmpty()) {
return new CrossRepresentationStepResult(List.of(), List.of());
}
List<DriverWorkingTimeVehicleUsageInterval> databaseIntervals = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> fileSessionIntervals = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> unknownIntervals = new ArrayList<>();
for (DriverWorkingTimeVehicleUsageInterval interval : normalizedCardIntervals) {
RuntimeVehicleUsageIntervalDescriptor descriptor = descriptorFactory.describe(interval);
RuntimeVehicleUsageIntervalRepresentation representation = descriptor == null
? RuntimeVehicleUsageIntervalRepresentation.UNKNOWN
: descriptor.representation();
switch (representation) {
case DATABASE -> databaseIntervals.add(interval);
case FILE_SESSION -> fileSessionIntervals.add(interval);
case UNKNOWN -> unknownIntervals.add(interval);
}
}
List<DriverWorkingTimeVehicleUsageInterval> effective = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> suppressed = new ArrayList<>();
Set<String> usedFileSessionKeys = new LinkedHashSet<>();
for (DriverWorkingTimeVehicleUsageInterval database : databaseIntervals) {
List<DriverWorkingTimeVehicleUsageInterval> matchingFileIntervals = fileSessionIntervals.stream()
.filter(Objects::nonNull)
.filter(file -> !usedFileSessionKeys.contains(intervalIdentity(file)))
.filter(file -> canReconcileCardRepresentations(database, file))
.sorted(Comparator.comparingLong(file -> crossRepresentationMatchScore(database, file)))
.toList();
if (matchingFileIntervals.isEmpty()) {
effective.add(database);
continue;
}
DriverWorkingTimeVehicleUsageInterval fused = database;
List<String> secondaryIds = new ArrayList<>();
List<String> secondaryTypes = new ArrayList<>();
for (DriverWorkingTimeVehicleUsageInterval file : matchingFileIntervals) {
usedFileSessionKeys.add(intervalIdentity(file));
suppressed.add(file);
secondaryIds.add(file.intervalId());
secondaryTypes.add(RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED.name()
+ ":" + RuntimeVehicleUsageIntervalRepresentation.FILE_SESSION.name());
fused = fuseCardRepresentationPrimary(fused, file);
}
effective.add(fused);
List<String> conflictWarnings = new ArrayList<>();
for (DriverWorkingTimeVehicleUsageInterval file : matchingFileIntervals) {
conflictWarnings.addAll(vehicleConflictWarnings(file, database));
}
warnings.addAll(conflictWarnings);
decisions.add(new RuntimeVehicleUsageReconciliationDecisionDto(
RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT,
"CROSS_REPRESENTATION_DUPLICATE_SUPPRESSED",
matchingFileIntervals.size() == 1
? equivalenceType(database, matchingFileIntervals.getFirst())
: "MULTIPLE_EQUIVALENT_FILE_SESSION_INTERVALS",
fused.intervalId(),
RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED.name()
+ ":" + RuntimeVehicleUsageIntervalRepresentation.DATABASE.name(),
secondaryIds,
secondaryTypes,
fused.startedAt(),
fused.endedAt(),
absoluteDeltaSeconds(database.startedAt(), matchingFileIntervals.getFirst().startedAt()),
absoluteDeltaSeconds(database.endedAt(), matchingFileIntervals.getFirst().endedAt()),
"Database and file-session CARD_VEHICLES_USED intervals describe the same normalized vehicle-usage period. Database identity remains primary; a wider one-sided boundary from the corroborating representation is preserved when the corresponding odometer boundary is unobserved.",
conflictWarnings
));
}
for (DriverWorkingTimeVehicleUsageInterval file : fileSessionIntervals) {
if (!usedFileSessionKeys.contains(intervalIdentity(file))) {
effective.add(file);
}
}
effective.addAll(unknownIntervals);
effective.sort(intervalComparator());
suppressed.sort(intervalComparator());
return new CrossRepresentationStepResult(List.copyOf(effective), List.copyOf(suppressed));
}
private boolean canReconcileCardRepresentations(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession
) {
if (database == null || fileSession == null
|| database.startedAt() == null || database.endedAt() == null
|| fileSession.startedAt() == null || fileSession.endedAt() == null) {
return false;
}
if (!Objects.equals(database.driverKey(), fileSession.driverKey())) {
return false;
}
if (!compatibleVehicleIdentity(database, fileSession)) {
return false;
}
if (!compatibleOdometer(database.odometerBeginKm(), fileSession.odometerBeginKm())
|| !compatibleOdometer(database.odometerEndKm(), fileSession.odometerEndKm())) {
return false;
}
return compatibleCrossRepresentationBoundaries(database, fileSession);
}
private boolean compatibleCrossRepresentationBoundaries(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
long startDelta = absoluteDeltaSeconds(left.startedAt(), right.startedAt());
long endDelta = absoluteDeltaSeconds(left.endedAt(), right.endedAt());
boolean startAligned = startDelta <= MATCH_TOLERANCE_SECONDS;
boolean endAligned = endDelta <= MATCH_TOLERANCE_SECONDS;
if (startAligned && endAligned) {
return true;
}
if (!intervalsOverlap(left, right)) {
return false;
}
// A missing odometer at a non-aligned boundary means that the representation
// did not observe the physical start/end and was clipped by its load window.
// The opposite aligned boundary still identifies the same physical CVU period.
if (endAligned && !startAligned) {
return left.odometerBeginKm() == null || right.odometerBeginKm() == null;
}
if (startAligned && !endAligned) {
return left.odometerEndKm() == null || right.odometerEndKm() == null;
}
return false;
}
private boolean intervalsOverlap(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
return left.startedAt().isBefore(right.endedAt())
&& right.startedAt().isBefore(left.endedAt());
}
private DriverWorkingTimeVehicleUsageInterval fuseCardRepresentationPrimary(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession
) {
long startDelta = absoluteDeltaSeconds(database.startedAt(), fileSession.startedAt());
long endDelta = absoluteDeltaSeconds(database.endedAt(), fileSession.endedAt());
OffsetDateTime start = startDelta <= MATCH_TOLERANCE_SECONDS
? database.startedAt()
: earliest(database.startedAt(), fileSession.startedAt());
OffsetDateTime end = endDelta <= MATCH_TOLERANCE_SECONDS
? database.endedAt()
: latest(database.endedAt(), fileSession.endedAt());
Long odometerBegin = boundaryOdometerAtStart(database, fileSession, start, startDelta);
Long odometerEnd = boundaryOdometerAtEnd(database, fileSession, end, endDelta);
Long startedAtEpochSecond = start == null ? null : start.toEpochSecond();
Long endedAtEpochSecond = end == null ? null : end.toEpochSecond();
Long durationSeconds = startedAtEpochSecond == null || endedAtEpochSecond == null
? database.durationSeconds()
: endedAtEpochSecond - startedAtEpochSecond;
return new DriverWorkingTimeVehicleUsageInterval(
database.sessionId(),
firstNonBlank(database.driverKey(), fileSession.driverKey()),
database.intervalId(),
firstNonBlank(database.firstSourceIntervalId(), database.intervalId()),
firstNonBlank(database.lastSourceIntervalId(), database.intervalId()),
start,
end,
startedAtEpochSecond,
endedAtEpochSecond,
durationSeconds,
odometerBegin,
odometerEnd,
firstNonBlank(database.registrationKey(), fileSession.registrationKey()),
firstNonBlank(database.vehicleKey(), fileSession.vehicleKey()),
firstNonBlank(database.sourceKind(), fileSession.sourceKind()),
sourceIntervalIds(database, fileSession)
);
}
private Long boundaryOdometerAtStart(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession,
OffsetDateTime selectedStart,
long startDelta
) {
if (startDelta <= MATCH_TOLERANCE_SECONDS) {
return firstNonNull(database.odometerBeginKm(), fileSession.odometerBeginKm());
}
if (Objects.equals(selectedStart, database.startedAt())) {
return database.odometerBeginKm();
}
return fileSession.odometerBeginKm();
}
private Long boundaryOdometerAtEnd(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession,
OffsetDateTime selectedEnd,
long endDelta
) {
if (endDelta <= MATCH_TOLERANCE_SECONDS) {
return firstNonNull(database.odometerEndKm(), fileSession.odometerEndKm());
}
if (Objects.equals(selectedEnd, database.endedAt())) {
return database.odometerEndKm();
}
return fileSession.odometerEndKm();
}
private OffsetDateTime earliest(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isBefore(right) ? left : right;
}
private OffsetDateTime latest(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isAfter(right) ? left : right;
}
private boolean compatibleOdometer(Long left, Long right) {
return left == null || right == null || Math.abs(left - right) <= 1L;
}
private ReconciliationStepResult reconcileCardWithIw(
List<DriverWorkingTimeVehicleUsageInterval> cardIntervals,
List<DriverWorkingTimeVehicleUsageInterval> iwIntervals,
@ -352,9 +642,36 @@ public class RuntimeVehicleUsageReconciliationService {
if ((start == null || start == 0L) && (end == null || end == 0L)) {
return "EXACT_INTERVAL_BOUNDARIES";
}
if (start != null && end != null) {
boolean startAligned = start <= MATCH_TOLERANCE_SECONDS;
boolean endAligned = end <= MATCH_TOLERANCE_SECONDS;
if (!startAligned && endAligned
&& (card.odometerBeginKm() == null || iw.odometerBeginKm() == null)) {
return "LEFT_BOUNDARY_CENSORED";
}
if (startAligned && !endAligned
&& (card.odometerEndKm() == null || iw.odometerEndKm() == null)) {
return "RIGHT_BOUNDARY_CENSORED";
}
}
return "COMPATIBLE_INTERVAL_BOUNDARIES";
}
private long crossRepresentationMatchScore(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession
) {
long start = absoluteDeltaSeconds(database.startedAt(), fileSession.startedAt());
long end = absoluteDeltaSeconds(database.endedAt(), fileSession.endedAt());
boolean startAligned = start <= MATCH_TOLERANCE_SECONDS;
boolean endAligned = end <= MATCH_TOLERANCE_SECONDS;
if (startAligned && endAligned) {
return start + end;
}
// Prefer exact/near-exact candidates over a boundary-censored candidate.
return 1_000_000_000L + (startAligned ? start : endAligned ? end : start + end);
}
private long matchScore(DriverWorkingTimeVehicleUsageInterval card, DriverWorkingTimeVehicleUsageInterval iw) {
Long start = absoluteDeltaSeconds(card.startedAt(), iw.startedAt());
Long end = absoluteDeltaSeconds(card.endedAt(), iw.endedAt());
@ -431,7 +748,7 @@ public class RuntimeVehicleUsageReconciliationService {
}
String id = interval.intervalId();
if (id != null && !id.isBlank()) {
return id;
return String.valueOf(interval.sessionId()) + "|" + id;
}
return interval.driverKey() + "|" + interval.registrationKey() + "|" + interval.vehicleKey()
+ "|" + interval.startedAt() + "|" + interval.endedAt();
@ -466,6 +783,12 @@ public class RuntimeVehicleUsageReconciliationService {
return null;
}
private record CrossRepresentationStepResult(
List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals,
List<DriverWorkingTimeVehicleUsageInterval> suppressedIntervals
) {
}
private record ReconciliationStepResult(
List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals,
List<DriverWorkingTimeVehicleUsageInterval> suppressedIntervals

View File

@ -0,0 +1,199 @@
package at.procon.eventhub.processing.eventprocessing.module;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import at.procon.eventhub.processing.eventprocessing.vehicleusage.RuntimeVehicleUsageReconciliationResult;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class DriverVehicleUsageMergeModuleTest {
private static final String DRIVER_KEY = "13:DF000358328840";
private static final String REGISTRATION_KEY = "13:RO BS 2219";
private static final UUID FILE_SESSION_ID = UUID.fromString("b7aa193e-d891-4b79-9d55-973441ad4f87");
private static final UUID DATABASE_SESSION_ID = new UUID(0L, 0L);
@Test
void mergesBoundaryCensoredFileAndDatabaseCvuAfterReconciliation() {
DriverWorkingTimeVehicleUsageInterval fileSession = interval(
FILE_SESSION_ID,
"CVU_MERGED|file",
"CVU-159",
"CVU-161",
"2026-03-31T00:00:00Z",
"2026-04-02T12:27:15Z",
null,
173268L,
null,
List.of("CVU-159", "CVU-160", "CVU-161")
);
DriverWorkingTimeVehicleUsageInterval database = interval(
DATABASE_SESSION_ID,
"CVU_MERGED|database",
"4200833",
"4200834",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
172945L,
173268L,
"XLRTEH4300G376073",
List.of("4200833", "4200834")
);
RuntimeProcessingModuleResult result = execute(List.of(fileSession, database));
@SuppressWarnings("unchecked")
List<DriverWorkingTimeVehicleUsageInterval> merged =
(List<DriverWorkingTimeVehicleUsageInterval>) result.output();
assertThat(merged).hasSize(1);
DriverWorkingTimeVehicleUsageInterval effective = merged.getFirst();
assertThat(effective.startedAt()).isEqualTo(OffsetDateTime.parse("2026-03-31T00:00:00Z"));
assertThat(effective.endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-02T12:27:15Z"));
assertThat(effective.sessionId()).isEqualTo(DATABASE_SESSION_ID);
assertThat(effective.intervalId()).isEqualTo("CVU_MERGED|database");
assertThat(effective.vehicleKey()).isEqualTo("XLRTEH4300G376073");
assertThat(effective.odometerBeginKm()).isNull();
assertThat(effective.odometerEndKm()).isEqualTo(173268L);
assertThat(effective.sourceIntervalIds()).contains(
"CVU_MERGED|file",
"CVU_MERGED|database",
"CVU-159",
"CVU-160",
"CVU-161",
"4200833",
"4200834"
);
assertThat(result.metadata()).containsEntry("inputIntervalCount", 2);
assertThat(result.metadata()).containsEntry("mergedIntervalCount", 1);
assertThat(result.metadata()).containsEntry("coalescedIntervalCount", 1);
}
@Test
void doesNotMergeOverlappingIntervalsWithConflictingVin() {
DriverWorkingTimeVehicleUsageInterval left = interval(
DATABASE_SESSION_ID,
"left",
"left",
"left",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
100L,
200L,
"VIN-1",
List.of("left")
);
DriverWorkingTimeVehicleUsageInterval right = interval(
FILE_SESSION_ID,
"right",
"right",
"right",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
100L,
200L,
"VIN-2",
List.of("right")
);
RuntimeProcessingModuleResult result = execute(List.of(left, right));
assertThat((List<?>) result.output()).hasSize(2);
}
@Test
void doesNotMergeAlignedIntervalsWithConflictingBoundaryOdometer() {
DriverWorkingTimeVehicleUsageInterval left = interval(
DATABASE_SESSION_ID,
"left",
"left",
"left",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
100L,
200L,
"VIN-1",
List.of("left")
);
DriverWorkingTimeVehicleUsageInterval right = interval(
FILE_SESSION_ID,
"right",
"right",
"right",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
120L,
200L,
"VIN-1",
List.of("right")
);
RuntimeProcessingModuleResult result = execute(List.of(left, right));
assertThat((List<?>) result.output()).hasSize(2);
}
private RuntimeProcessingModuleResult execute(List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals) {
RuntimeVehicleUsageReconciliationResult reconciliation = new RuntimeVehicleUsageReconciliationResult(
effectiveIntervals,
effectiveIntervals,
effectiveIntervals,
effectiveIntervals,
List.of(),
List.of(),
List.of(),
List.of()
);
RuntimeProcessingModuleResult previous = new RuntimeProcessingModuleResult(
DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION,
RuntimeProcessingModuleStatus.SUCCESS,
reconciliation,
Map.of(),
List.of()
);
RuntimeProcessingModuleContext context = new RuntimeProcessingModuleContext(
null,
List.of(),
Map.of(),
Map.of(DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION, previous)
);
return new DriverVehicleUsageMergeModule().execute(context);
}
private DriverWorkingTimeVehicleUsageInterval interval(
UUID sessionId,
String intervalId,
String firstSourceIntervalId,
String lastSourceIntervalId,
String startedAt,
String endedAt,
Long odometerBeginKm,
Long odometerEndKm,
String vehicleKey,
List<String> sourceIds
) {
OffsetDateTime start = OffsetDateTime.parse(startedAt);
OffsetDateTime end = OffsetDateTime.parse(endedAt);
return new DriverWorkingTimeVehicleUsageInterval(
sessionId,
DRIVER_KEY,
intervalId,
firstSourceIntervalId,
lastSourceIntervalId,
start,
end,
start.toEpochSecond(),
end.toEpochSecond(),
end.toEpochSecond() - start.toEpochSecond(),
odometerBeginKm,
odometerEndKm,
REGISTRATION_KEY,
vehicleKey,
"DRIVER_CARD",
sourceIds
);
}
}

View File

@ -94,6 +94,199 @@ class RuntimeVehicleUsageReconciliationServiceTest {
);
}
@Test
void normalizesCvuPerRepresentationBeforeReconcilingDatabaseAndFileSessionIntervals() {
UUID fileSessionId = UUID.fromString("22222222-2222-2222-2222-222222222222");
UUID databaseSessionId = new UUID(0L, 0L);
DriverWorkingTimeVehicleUsageInterval fileDayOne = interval(
fileSessionId,
"CVU-159",
"CVU-159",
"CVU-160",
"2026-04-01T00:00:00Z",
"2026-04-01T23:59:59Z",
"DRIVER_CARD",
null,
100L,
250L
);
DriverWorkingTimeVehicleUsageInterval databaseFullInterval = interval(
databaseSessionId,
"TACHOGRAPH:CARD_VEHICLES_USED:4200833",
"4200833",
"4200834",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
"DRIVER_CARD",
"VIN:WDB9634031L123456",
100L,
400L
);
DriverWorkingTimeVehicleUsageInterval fileDayTwo = interval(
fileSessionId,
"CVU-161",
"CVU-161",
"CVU-161",
"2026-04-02T00:00:00Z",
"2026-04-02T12:27:15Z",
"DRIVER_CARD",
null,
250L,
400L
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(
fileDayOne,
databaseFullInterval,
fileDayTwo
));
assertThat(result.normalizedCardVehicleUsedIntervals()).hasSize(2);
assertThat(result.representationReconciledCardVehicleUsedIntervals()).hasSize(1);
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(1);
assertThat(result.suppressedSecondaryIntervals()).hasSize(1);
DriverWorkingTimeVehicleUsageInterval effective = result.effectiveVehicleUsageIntervals().getFirst();
assertThat(effective.sessionId()).isEqualTo(databaseSessionId);
assertThat(effective.intervalId()).isEqualTo("TACHOGRAPH:CARD_VEHICLES_USED:4200833");
assertThat(effective.startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T00:00:00Z"));
assertThat(effective.endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-02T12:27:15Z"));
assertThat(effective.vehicleKey()).isEqualTo("VIN:WDB9634031L123456");
assertThat(effective.sourceIntervalIds()).contains("CVU-159", "CVU-160", "CVU-161", "4200833", "4200834");
assertThat(result.vehicleUsageReconciliationDecisions())
.extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.contains(
RuntimeVehicleUsageReconciliationService.RULE_CVU_MIDNIGHT_CONTINUATION,
RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT
);
}
@Test
void reconcilesLeftBoundaryCensoredFileSessionCvuWithDatabaseInterval() {
UUID fileSessionId = UUID.fromString("44444444-4444-4444-4444-444444444444");
UUID databaseSessionId = new UUID(0L, 0L);
DriverWorkingTimeVehicleUsageInterval fileSession = interval(
fileSessionId,
"CVU_MERGED|driver|vehicle|2026-03-31T00:00Z|2026-04-02T12:27:15Z",
"CVU-159",
"CVU-161",
"2026-03-31T00:00:00Z",
"2026-04-02T12:27:15Z",
"DRIVER_CARD",
null,
null,
173268L
);
DriverWorkingTimeVehicleUsageInterval database = interval(
databaseSessionId,
"TACHOGRAPH:CARD_VEHICLES_USED:4200833",
"4200833",
"4200834",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
"DRIVER_CARD",
"VIN:XLRTEH4300G376073",
172945L,
173268L
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(fileSession, database));
assertThat(result.representationReconciledCardVehicleUsedIntervals()).hasSize(1);
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(1);
assertThat(result.suppressedSecondaryIntervals()).hasSize(1);
DriverWorkingTimeVehicleUsageInterval effective = result.effectiveVehicleUsageIntervals().getFirst();
assertThat(effective.sessionId()).isEqualTo(databaseSessionId);
assertThat(effective.intervalId()).isEqualTo("TACHOGRAPH:CARD_VEHICLES_USED:4200833");
assertThat(effective.startedAt()).isEqualTo(OffsetDateTime.parse("2026-03-31T00:00:00Z"));
assertThat(effective.endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-02T12:27:15Z"));
assertThat(effective.odometerBeginKm()).isNull();
assertThat(effective.odometerEndKm()).isEqualTo(173268L);
assertThat(effective.vehicleKey()).isEqualTo("VIN:XLRTEH4300G376073");
assertThat(effective.sourceIntervalIds()).contains("CVU-159", "CVU-161", "4200833", "4200834");
assertThat(result.vehicleUsageReconciliationDecisions())
.filteredOn(decision -> RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT
.equals(decision.ruleId()))
.extracting(RuntimeVehicleUsageReconciliationDecisionDto::equivalenceType)
.containsExactly("LEFT_BOUNDARY_CENSORED");
}
@Test
void doesNotReconcileDifferentKnownStartsOnlyBecauseEndsMatch() {
DriverWorkingTimeVehicleUsageInterval database = interval(
new UUID(0L, 0L),
"TACHOGRAPH:CARD_VEHICLES_USED:10",
"10",
"11",
"2026-04-01T08:00:00Z",
"2026-04-01T12:00:00Z",
"DRIVER_CARD",
"VIN:WDB9634031L123456",
100L,
200L
);
DriverWorkingTimeVehicleUsageInterval file = interval(
UUID.fromString("55555555-5555-5555-5555-555555555555"),
"CVU-10",
"CVU-10",
"CVU-10",
"2026-04-01T09:00:00Z",
"2026-04-01T12:00:00Z",
"DRIVER_CARD",
null,
120L,
200L
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(database, file));
assertThat(result.representationReconciledCardVehicleUsedIntervals()).hasSize(2);
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(2);
assertThat(result.vehicleUsageReconciliationDecisions())
.extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.doesNotContain(RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT);
}
@Test
void keepsCrossRepresentationCvuIntervalsSeparateWhenOdometerBoundariesConflict() {
DriverWorkingTimeVehicleUsageInterval database = interval(
new UUID(0L, 0L),
"TACHOGRAPH:CARD_VEHICLES_USED:1",
"1",
"2",
"2026-04-01T08:00:00Z",
"2026-04-01T12:00:00Z",
"DRIVER_CARD",
"VIN:WDB9634031L123456",
100L,
200L
);
DriverWorkingTimeVehicleUsageInterval file = interval(
UUID.fromString("33333333-3333-3333-3333-333333333333"),
"CVU-1",
"CVU-1",
"CVU-1",
"2026-04-01T08:00:00Z",
"2026-04-01T12:00:00Z",
"DRIVER_CARD",
null,
100L,
250L
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(database, file));
assertThat(result.representationReconciledCardVehicleUsedIntervals()).hasSize(2);
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(2);
assertThat(result.vehicleUsageReconciliationDecisions())
.extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.doesNotContain(RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT);
}
@Test
void keepsCardVehiclesUsedAsFallbackWhenIwCycleIsMissing() {
DriverWorkingTimeVehicleUsageInterval card = interval(
@ -137,11 +330,37 @@ class RuntimeVehicleUsageReconciliationServiceTest {
String start,
String end,
String sourceKind
) {
return interval(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
intervalId,
firstSourceIntervalId,
lastSourceIntervalId,
start,
end,
sourceKind,
"VIN:WDB9634031L123456",
null,
null
);
}
private DriverWorkingTimeVehicleUsageInterval interval(
UUID sessionId,
String intervalId,
String firstSourceIntervalId,
String lastSourceIntervalId,
String start,
String end,
String sourceKind,
String vehicleKey,
Long odometerBeginKm,
Long odometerEndKm
) {
OffsetDateTime startedAt = OffsetDateTime.parse(start);
OffsetDateTime endedAt = OffsetDateTime.parse(end);
return new DriverWorkingTimeVehicleUsageInterval(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
sessionId,
"1:driver",
intervalId,
firstSourceIntervalId,
@ -151,10 +370,10 @@ class RuntimeVehicleUsageReconciliationServiceTest {
startedAt.toEpochSecond(),
endedAt.toEpochSecond(),
endedAt.toEpochSecond() - startedAt.toEpochSecond(),
null,
null,
odometerBeginKm,
odometerEndKm,
"1:LL-158TE",
"VIN:WDB9634031L123456",
vehicleKey,
sourceKind,
List.of(firstSourceIntervalId, lastSourceIntervalId)
);