Reconcile CVU intervals across representations
This commit is contained in:
parent
1dd6d127a4
commit
34e6c6f236
|
|
@ -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.
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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)
|
||||||
|
```
|
||||||
|
|
@ -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)
|
||||||
|
```
|
||||||
|
|
@ -12,6 +12,7 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
|
|
@ -27,7 +28,7 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
|
||||||
return new RuntimeProcessingModuleDescriptorDto(
|
return new RuntimeProcessingModuleDescriptorDto(
|
||||||
moduleKey(),
|
moduleKey(),
|
||||||
"Vehicle usage merge",
|
"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",
|
"JAVA",
|
||||||
Set.of(DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION),
|
Set.of(DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION),
|
||||||
Set.of("RuntimeVehicleUsageReconciliationResult.effectiveVehicleUsageIntervals"),
|
Set.of("RuntimeVehicleUsageReconciliationResult.effectiveVehicleUsageIntervals"),
|
||||||
|
|
@ -43,6 +44,7 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
|
||||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||||
metadata.put("inputIntervalCount", intervals.size());
|
metadata.put("inputIntervalCount", intervals.size());
|
||||||
metadata.put("mergedIntervalCount", merged.size());
|
metadata.put("mergedIntervalCount", merged.size());
|
||||||
|
metadata.put("coalescedIntervalCount", Math.max(0, intervals.size() - merged.size()));
|
||||||
return new RuntimeProcessingModuleResult(
|
return new RuntimeProcessingModuleResult(
|
||||||
moduleKey(),
|
moduleKey(),
|
||||||
RuntimeProcessingModuleStatus.SUCCESS,
|
RuntimeProcessingModuleStatus.SUCCESS,
|
||||||
|
|
@ -100,42 +102,248 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
|
||||||
DriverWorkingTimeVehicleUsageInterval left,
|
DriverWorkingTimeVehicleUsageInterval left,
|
||||||
DriverWorkingTimeVehicleUsageInterval right
|
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 false;
|
||||||
}
|
}
|
||||||
return Objects.equals(left.driverKey(), right.driverKey())
|
return Objects.equals(left.driverKey(), right.driverKey())
|
||||||
&& Objects.equals(left.registrationKey(), right.registrationKey())
|
&& compatibleVehicleIdentity(left, right)
|
||||||
&& Objects.equals(left.vehicleKey(), right.vehicleKey())
|
&& compatibleAlignedBoundaryOdometers(left, right)
|
||||||
&& !right.startedAt().isAfter(left.endedAt().plusSeconds(1));
|
&& !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(
|
private DriverWorkingTimeVehicleUsageInterval merge(
|
||||||
DriverWorkingTimeVehicleUsageInterval left,
|
DriverWorkingTimeVehicleUsageInterval left,
|
||||||
DriverWorkingTimeVehicleUsageInterval right
|
DriverWorkingTimeVehicleUsageInterval right
|
||||||
) {
|
) {
|
||||||
LinkedHashSet<String> sourceIntervalIds = new LinkedHashSet<>(left.sourceIntervalIds());
|
DriverWorkingTimeVehicleUsageInterval primary = richer(left, right);
|
||||||
sourceIntervalIds.addAll(right.sourceIntervalIds());
|
DriverWorkingTimeVehicleUsageInterval secondary = primary == left ? right : left;
|
||||||
OffsetDateTime mergedEnd = left.endedAt();
|
OffsetDateTime mergedStart = earliest(left.startedAt(), right.startedAt());
|
||||||
if (right.endedAt() != null && (mergedEnd == null || right.endedAt().isAfter(mergedEnd))) {
|
OffsetDateTime mergedEnd = latest(left.endedAt(), right.endedAt());
|
||||||
mergedEnd = right.endedAt();
|
|
||||||
}
|
LinkedHashSet<String> sourceIntervalIds = new LinkedHashSet<>();
|
||||||
|
addSourceIds(sourceIntervalIds, left);
|
||||||
|
addSourceIds(sourceIntervalIds, right);
|
||||||
|
|
||||||
return new DriverWorkingTimeVehicleUsageInterval(
|
return new DriverWorkingTimeVehicleUsageInterval(
|
||||||
left.sessionId(),
|
firstNonNull(primary.sessionId(), secondary.sessionId()),
|
||||||
left.driverKey(),
|
firstNonBlank(primary.driverKey(), secondary.driverKey()),
|
||||||
left.intervalId(),
|
firstNonBlank(primary.intervalId(), secondary.intervalId()),
|
||||||
left.firstSourceIntervalId(),
|
firstSourceIntervalId(left, right, mergedStart),
|
||||||
right.lastSourceIntervalId() == null ? left.lastSourceIntervalId() : right.lastSourceIntervalId(),
|
lastSourceIntervalId(left, right, mergedEnd),
|
||||||
left.startedAt(),
|
mergedStart,
|
||||||
mergedEnd,
|
mergedEnd,
|
||||||
left.startedAtEpochSecond(),
|
mergedStart.toEpochSecond(),
|
||||||
mergedEnd == null ? null : mergedEnd.toEpochSecond(),
|
mergedEnd.toEpochSecond(),
|
||||||
mergedEnd == null ? left.durationSeconds() : mergedEnd.toEpochSecond() - left.startedAtEpochSecond(),
|
mergedEnd.toEpochSecond() - mergedStart.toEpochSecond(),
|
||||||
left.odometerBeginKm(),
|
odometerAtStart(left, right, mergedStart, primary),
|
||||||
right.odometerEndKm() == null ? left.odometerEndKm() : right.odometerEndKm(),
|
odometerAtEnd(left, right, mergedEnd, primary),
|
||||||
left.registrationKey(),
|
firstNonBlank(primary.registrationKey(), secondary.registrationKey()),
|
||||||
left.vehicleKey(),
|
firstNonBlank(primary.vehicleKey(), secondary.vehicleKey()),
|
||||||
left.sourceKind(),
|
firstNonBlank(primary.sourceKind(), secondary.sourceKind()),
|
||||||
List.copyOf(sourceIntervalIds)
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ public class VehicleUsageReconciliationModule implements RuntimeProcessingModule
|
||||||
return new RuntimeProcessingModuleDescriptorDto(
|
return new RuntimeProcessingModuleDescriptorDto(
|
||||||
moduleKey(),
|
moduleKey(),
|
||||||
"Vehicle usage reconciliation",
|
"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",
|
"JAVA",
|
||||||
Set.of(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS),
|
Set.of(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS),
|
||||||
Set.of("DriverVehicleUsageIntervalEvent"),
|
Set.of("DriverVehicleUsageIntervalEvent"),
|
||||||
|
|
@ -51,8 +51,13 @@ public class VehicleUsageReconciliationModule implements RuntimeProcessingModule
|
||||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||||
metadata.put("inputVehicleUsageIntervalCount", result.rawVehicleUsageIntervals().size());
|
metadata.put("inputVehicleUsageIntervalCount", result.rawVehicleUsageIntervals().size());
|
||||||
metadata.put("normalizedCardVehicleUsedIntervalCount", result.normalizedCardVehicleUsedIntervals().size());
|
metadata.put("normalizedCardVehicleUsedIntervalCount", result.normalizedCardVehicleUsedIntervals().size());
|
||||||
|
metadata.put("representationReconciledCardVehicleUsedIntervalCount", result.representationReconciledCardVehicleUsedIntervals().size());
|
||||||
metadata.put("effectiveVehicleUsageIntervalCount", result.effectiveVehicleUsageIntervals().size());
|
metadata.put("effectiveVehicleUsageIntervalCount", result.effectiveVehicleUsageIntervals().size());
|
||||||
metadata.put("suppressedSecondaryIntervalCount", result.suppressedSecondaryIntervals().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("vehicleUsageReconciliationDecisionCount", result.vehicleUsageReconciliationDecisions().size());
|
||||||
metadata.put("vehicleUsageReconciliationDecisions", result.vehicleUsageReconciliationDecisions());
|
metadata.put("vehicleUsageReconciliationDecisions", result.vehicleUsageReconciliationDecisions());
|
||||||
metadata.put("notes", result.notes());
|
metadata.put("notes", result.notes());
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import java.time.OffsetDateTime;
|
||||||
public record RuntimeVehicleUsageIntervalDescriptor(
|
public record RuntimeVehicleUsageIntervalDescriptor(
|
||||||
DriverWorkingTimeVehicleUsageInterval interval,
|
DriverWorkingTimeVehicleUsageInterval interval,
|
||||||
RuntimeVehicleUsageIntervalSourceType sourceType,
|
RuntimeVehicleUsageIntervalSourceType sourceType,
|
||||||
|
RuntimeVehicleUsageIntervalRepresentation representation,
|
||||||
String sourceKind,
|
String sourceKind,
|
||||||
String driverKey,
|
String driverKey,
|
||||||
String registrationKey,
|
String registrationKey,
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVe
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class RuntimeVehicleUsageIntervalDescriptorFactory {
|
public class RuntimeVehicleUsageIntervalDescriptorFactory {
|
||||||
|
|
||||||
|
private static final UUID ZERO_UUID = new UUID(0L, 0L);
|
||||||
|
|
||||||
public RuntimeVehicleUsageIntervalDescriptor describe(DriverWorkingTimeVehicleUsageInterval interval) {
|
public RuntimeVehicleUsageIntervalDescriptor describe(DriverWorkingTimeVehicleUsageInterval interval) {
|
||||||
if (interval == null) {
|
if (interval == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -16,6 +19,7 @@ public class RuntimeVehicleUsageIntervalDescriptorFactory {
|
||||||
return new RuntimeVehicleUsageIntervalDescriptor(
|
return new RuntimeVehicleUsageIntervalDescriptor(
|
||||||
interval,
|
interval,
|
||||||
sourceType(interval),
|
sourceType(interval),
|
||||||
|
representation(interval),
|
||||||
interval.sourceKind(),
|
interval.sourceKind(),
|
||||||
interval.driverKey(),
|
interval.driverKey(),
|
||||||
interval.registrationKey(),
|
interval.registrationKey(),
|
||||||
|
|
@ -41,6 +45,24 @@ public class RuntimeVehicleUsageIntervalDescriptorFactory {
|
||||||
return RuntimeVehicleUsageIntervalSourceType.OTHER;
|
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) {
|
private List<String> identifiers(DriverWorkingTimeVehicleUsageInterval interval) {
|
||||||
List<String> result = new ArrayList<>();
|
List<String> result = new ArrayList<>();
|
||||||
add(result, interval.intervalId());
|
add(result, interval.intervalId());
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import java.util.List;
|
||||||
public record RuntimeVehicleUsageReconciliationResult(
|
public record RuntimeVehicleUsageReconciliationResult(
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> rawVehicleUsageIntervals,
|
List<DriverWorkingTimeVehicleUsageInterval> rawVehicleUsageIntervals,
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardVehicleUsedIntervals,
|
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardVehicleUsedIntervals,
|
||||||
|
List<DriverWorkingTimeVehicleUsageInterval> representationReconciledCardVehicleUsedIntervals,
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> effectiveVehicleUsageIntervals,
|
List<DriverWorkingTimeVehicleUsageInterval> effectiveVehicleUsageIntervals,
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> suppressedSecondaryIntervals,
|
List<DriverWorkingTimeVehicleUsageInterval> suppressedSecondaryIntervals,
|
||||||
List<RuntimeVehicleUsageReconciliationDecisionDto> vehicleUsageReconciliationDecisions,
|
List<RuntimeVehicleUsageReconciliationDecisionDto> vehicleUsageReconciliationDecisions,
|
||||||
|
|
@ -15,6 +16,9 @@ public record RuntimeVehicleUsageReconciliationResult(
|
||||||
public RuntimeVehicleUsageReconciliationResult {
|
public RuntimeVehicleUsageReconciliationResult {
|
||||||
rawVehicleUsageIntervals = rawVehicleUsageIntervals == null ? List.of() : List.copyOf(rawVehicleUsageIntervals);
|
rawVehicleUsageIntervals = rawVehicleUsageIntervals == null ? List.of() : List.copyOf(rawVehicleUsageIntervals);
|
||||||
normalizedCardVehicleUsedIntervals = normalizedCardVehicleUsedIntervals == null ? List.of() : List.copyOf(normalizedCardVehicleUsedIntervals);
|
normalizedCardVehicleUsedIntervals = normalizedCardVehicleUsedIntervals == null ? List.of() : List.copyOf(normalizedCardVehicleUsedIntervals);
|
||||||
|
representationReconciledCardVehicleUsedIntervals = representationReconciledCardVehicleUsedIntervals == null
|
||||||
|
? List.of()
|
||||||
|
: List.copyOf(representationReconciledCardVehicleUsedIntervals);
|
||||||
effectiveVehicleUsageIntervals = effectiveVehicleUsageIntervals == null ? List.of() : List.copyOf(effectiveVehicleUsageIntervals);
|
effectiveVehicleUsageIntervals = effectiveVehicleUsageIntervals == null ? List.of() : List.copyOf(effectiveVehicleUsageIntervals);
|
||||||
suppressedSecondaryIntervals = suppressedSecondaryIntervals == null ? List.of() : List.copyOf(suppressedSecondaryIntervals);
|
suppressedSecondaryIntervals = suppressedSecondaryIntervals == null ? List.of() : List.copyOf(suppressedSecondaryIntervals);
|
||||||
vehicleUsageReconciliationDecisions = vehicleUsageReconciliationDecisions == null ? List.of() : List.copyOf(vehicleUsageReconciliationDecisions);
|
vehicleUsageReconciliationDecisions = vehicleUsageReconciliationDecisions == null ? List.of() : List.copyOf(vehicleUsageReconciliationDecisions);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import java.time.LocalTime;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.EnumMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -18,6 +19,8 @@ import org.springframework.stereotype.Service;
|
||||||
public class RuntimeVehicleUsageReconciliationService {
|
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_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_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_CVU_FALLBACK = "tachograph.vehicle-usage.card-vehicles-used-fallback";
|
||||||
public static final String RULE_IW_PRIMARY = "tachograph.vehicle-usage.iw-cycle-primary";
|
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);
|
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardIntervals =
|
||||||
ReconciliationStepResult reconciled = reconcileCardWithIw(normalizedCardIntervals, iwIntervals, decisions, warnings);
|
normalizeCardVehicleUsedByRepresentation(cardIntervals, decisions);
|
||||||
|
CrossRepresentationStepResult representationReconciled =
|
||||||
|
reconcileCardRepresentations(normalizedCardIntervals, decisions, warnings);
|
||||||
|
ReconciliationStepResult reconciled = reconcileCardWithIw(
|
||||||
|
representationReconciled.effectiveIntervals(),
|
||||||
|
iwIntervals,
|
||||||
|
decisions,
|
||||||
|
warnings
|
||||||
|
);
|
||||||
|
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> effective = new ArrayList<>();
|
List<DriverWorkingTimeVehicleUsageInterval> effective = new ArrayList<>();
|
||||||
effective.addAll(reconciled.effectiveIntervals());
|
effective.addAll(reconciled.effectiveIntervals());
|
||||||
effective.addAll(otherIntervals);
|
effective.addAll(otherIntervals);
|
||||||
effective.sort(intervalComparator());
|
effective.sort(intervalComparator());
|
||||||
|
|
||||||
|
List<DriverWorkingTimeVehicleUsageInterval> suppressed = new ArrayList<>();
|
||||||
|
suppressed.addAll(representationReconciled.suppressedIntervals());
|
||||||
|
suppressed.addAll(reconciled.suppressedIntervals());
|
||||||
|
suppressed.sort(intervalComparator());
|
||||||
|
|
||||||
List<String> notes = List.of(
|
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(
|
return new RuntimeVehicleUsageReconciliationResult(
|
||||||
raw,
|
raw,
|
||||||
normalizedCardIntervals,
|
normalizedCardIntervals,
|
||||||
|
representationReconciled.effectiveIntervals(),
|
||||||
effective,
|
effective,
|
||||||
reconciled.suppressedIntervals(),
|
suppressed,
|
||||||
decisions,
|
decisions,
|
||||||
notes,
|
notes,
|
||||||
warnings
|
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(
|
private List<DriverWorkingTimeVehicleUsageInterval> normalizeCardVehicleUsed(
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> intervals,
|
List<DriverWorkingTimeVehicleUsageInterval> intervals,
|
||||||
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions
|
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions
|
||||||
|
|
@ -122,6 +168,250 @@ public class RuntimeVehicleUsageReconciliationService {
|
||||||
return List.copyOf(normalized);
|
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(
|
private ReconciliationStepResult reconcileCardWithIw(
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> cardIntervals,
|
List<DriverWorkingTimeVehicleUsageInterval> cardIntervals,
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> iwIntervals,
|
List<DriverWorkingTimeVehicleUsageInterval> iwIntervals,
|
||||||
|
|
@ -352,9 +642,36 @@ public class RuntimeVehicleUsageReconciliationService {
|
||||||
if ((start == null || start == 0L) && (end == null || end == 0L)) {
|
if ((start == null || start == 0L) && (end == null || end == 0L)) {
|
||||||
return "EXACT_INTERVAL_BOUNDARIES";
|
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";
|
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) {
|
private long matchScore(DriverWorkingTimeVehicleUsageInterval card, DriverWorkingTimeVehicleUsageInterval iw) {
|
||||||
Long start = absoluteDeltaSeconds(card.startedAt(), iw.startedAt());
|
Long start = absoluteDeltaSeconds(card.startedAt(), iw.startedAt());
|
||||||
Long end = absoluteDeltaSeconds(card.endedAt(), iw.endedAt());
|
Long end = absoluteDeltaSeconds(card.endedAt(), iw.endedAt());
|
||||||
|
|
@ -431,7 +748,7 @@ public class RuntimeVehicleUsageReconciliationService {
|
||||||
}
|
}
|
||||||
String id = interval.intervalId();
|
String id = interval.intervalId();
|
||||||
if (id != null && !id.isBlank()) {
|
if (id != null && !id.isBlank()) {
|
||||||
return id;
|
return String.valueOf(interval.sessionId()) + "|" + id;
|
||||||
}
|
}
|
||||||
return interval.driverKey() + "|" + interval.registrationKey() + "|" + interval.vehicleKey()
|
return interval.driverKey() + "|" + interval.registrationKey() + "|" + interval.vehicleKey()
|
||||||
+ "|" + interval.startedAt() + "|" + interval.endedAt();
|
+ "|" + interval.startedAt() + "|" + interval.endedAt();
|
||||||
|
|
@ -466,6 +783,12 @@ public class RuntimeVehicleUsageReconciliationService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record CrossRepresentationStepResult(
|
||||||
|
List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals,
|
||||||
|
List<DriverWorkingTimeVehicleUsageInterval> suppressedIntervals
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
private record ReconciliationStepResult(
|
private record ReconciliationStepResult(
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals,
|
List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals,
|
||||||
List<DriverWorkingTimeVehicleUsageInterval> suppressedIntervals
|
List<DriverWorkingTimeVehicleUsageInterval> suppressedIntervals
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
@Test
|
||||||
void keepsCardVehiclesUsedAsFallbackWhenIwCycleIsMissing() {
|
void keepsCardVehiclesUsedAsFallbackWhenIwCycleIsMissing() {
|
||||||
DriverWorkingTimeVehicleUsageInterval card = interval(
|
DriverWorkingTimeVehicleUsageInterval card = interval(
|
||||||
|
|
@ -137,11 +330,37 @@ class RuntimeVehicleUsageReconciliationServiceTest {
|
||||||
String start,
|
String start,
|
||||||
String end,
|
String end,
|
||||||
String sourceKind
|
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 startedAt = OffsetDateTime.parse(start);
|
||||||
OffsetDateTime endedAt = OffsetDateTime.parse(end);
|
OffsetDateTime endedAt = OffsetDateTime.parse(end);
|
||||||
return new DriverWorkingTimeVehicleUsageInterval(
|
return new DriverWorkingTimeVehicleUsageInterval(
|
||||||
UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
sessionId,
|
||||||
"1:driver",
|
"1:driver",
|
||||||
intervalId,
|
intervalId,
|
||||||
firstSourceIntervalId,
|
firstSourceIntervalId,
|
||||||
|
|
@ -151,10 +370,10 @@ class RuntimeVehicleUsageReconciliationServiceTest {
|
||||||
startedAt.toEpochSecond(),
|
startedAt.toEpochSecond(),
|
||||||
endedAt.toEpochSecond(),
|
endedAt.toEpochSecond(),
|
||||||
endedAt.toEpochSecond() - startedAt.toEpochSecond(),
|
endedAt.toEpochSecond() - startedAt.toEpochSecond(),
|
||||||
null,
|
odometerBeginKm,
|
||||||
null,
|
odometerEndKm,
|
||||||
"1:LL-158TE",
|
"1:LL-158TE",
|
||||||
"VIN:WDB9634031L123456",
|
vehicleKey,
|
||||||
sourceKind,
|
sourceKind,
|
||||||
List.of(firstSourceIntervalId, lastSourceIntervalId)
|
List.of(firstSourceIntervalId, lastSourceIntervalId)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue