From 9e6f8efb26aa6bb618ef3da570951c4faef12cba Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Tue, 12 May 2026 18:43:40 +0200 Subject: [PATCH] Keep open tachograph intervals without synthetic tails --- .../model/ResolvedVehicleUsageInterval.java | 2 +- .../DriverCardXmlExtractionService.java | 34 ++++++---- .../service/DriverTimelineBuilder.java | 19 ++++-- .../VehicleUnitXmlExtractionService.java | 18 ++--- .../DriverCardXmlExtractionServiceTest.java | 35 ++++++++-- .../service/DriverCardXmlSamples.java | 5 ++ .../service/DriverTimelineBuilderTest.java | 66 +++++++++++++++++++ .../VehicleUnitXmlExtractionServiceTest.java | 37 +++++++++-- 8 files changed, 172 insertions(+), 44 deletions(-) diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedVehicleUsageInterval.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedVehicleUsageInterval.java index bdb946c..e0080e5 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedVehicleUsageInterval.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedVehicleUsageInterval.java @@ -31,7 +31,7 @@ public record ResolvedVehicleUsageInterval( intervalId, from, to, - Duration.between(from, to).getSeconds(), + to == null ? 0L : Duration.between(from, to).getSeconds(), odometerBeginKm, odometerEndKm, registrationKey, diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java index e51052b..750ba8e 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java @@ -171,6 +171,14 @@ public class DriverCardXmlExtractionService { String path = "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords[" + (i + 1) + "]"; OffsetDateTime from = offsetDateTime(childText(record, "vehicleFirstUse")); OffsetDateTime to = offsetDateTime(childText(record, "vehicleLastUse")); + if (from == null) { + warnings.add(new ExtractionWarning("MISSING_VEHICLE_FIRST_USE", "Driver-card vehicle record is missing vehicleFirstUse.", path)); + continue; + } + if (to != null && to.isBefore(from)) { + warnings.add(new ExtractionWarning("INVALID_VEHICLE_USE_INTERVAL", "Driver-card vehicle record has an invalid first/last use range.", path)); + continue; + } Element vehicleRegistration = child(record, "vehicleRegistration"); String registrationNation = childText(vehicleRegistration, "vehicleRegistrationNation"); String registrationNumber = childText(child(vehicleRegistration, "vehicleRegistrationNumber"), "vehicleRegNumber"); @@ -192,9 +200,8 @@ public class DriverCardXmlExtractionService { new ExtractedVehicle(vehicleKey, vehicleKeyFactory.createSourceVehicleId(vehicleKey), vin) ); } - if (from == null || to == null) { - warnings.add(new ExtractionWarning("INCOMPLETE_VEHICLE_USAGE", "Vehicle usage interval is missing start or end timestamp.", path)); - continue; + if (to == null) { + warnings.add(new ExtractionWarning("OPEN_VEHICLE_USAGE", "Driver-card vehicle record has no vehicleLastUse and was kept open-ended.", path)); } intervals.add(new ExtractedCardVehicleUsageInterval( "CVU-" + (i + 1), @@ -244,11 +251,9 @@ public class DriverCardXmlExtractionService { )); } parsedChanges.sort(Comparator.comparing(ActivityChange::from)); - for (int i = 0; i < parsedChanges.size(); i++) { + for (int i = 0; i + 1 < parsedChanges.size(); i++) { ActivityChange current = parsedChanges.get(i); - OffsetDateTime to = i + 1 < parsedChanges.size() - ? parsedChanges.get(i + 1).from() - : date.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC); + OffsetDateTime to = parsedChanges.get(i + 1).from(); if (!current.from().isBefore(to)) { continue; } @@ -279,7 +284,7 @@ public class DriverCardXmlExtractionService { int usageStartIndex = 0; for (ExtractedCardActivityInterval interval : activityIntervals) { while (usageStartIndex < vehicleUsageIntervals.size() - && !endExclusive(vehicleUsageIntervals.get(usageStartIndex).to()).isAfter(interval.from())) { + && !usageEndExclusive(vehicleUsageIntervals.get(usageStartIndex), interval.to()).isAfter(interval.from())) { usageStartIndex++; } @@ -309,7 +314,7 @@ public class DriverCardXmlExtractionService { if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) { cutPoints.add(usage.from()); } - OffsetDateTime usageEndExclusive = endExclusive(usage.to()); + OffsetDateTime usageEndExclusive = usageEndExclusive(usage, interval.to()); if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) { cutPoints.add(usageEndExclusive); } @@ -326,7 +331,7 @@ public class DriverCardXmlExtractionService { } while (coverageIndex < overlappingUsages.size() - && !endExclusive(overlappingUsages.get(coverageIndex).to()).isAfter(segmentFrom)) { + && !usageEndExclusive(overlappingUsages.get(coverageIndex), interval.to()).isAfter(segmentFrom)) { coverageIndex++; } ExtractedCardVehicleUsageInterval covering = coverageIndex < overlappingUsages.size() @@ -351,11 +356,14 @@ public class DriverCardXmlExtractionService { } private boolean covers(ExtractedCardVehicleUsageInterval usage, OffsetDateTime timestamp) { - return !usage.from().isAfter(timestamp) && timestamp.isBefore(endExclusive(usage.to())); + return !usage.from().isAfter(timestamp) && timestamp.isBefore(usageEndExclusive(usage, null)); } - private OffsetDateTime endExclusive(OffsetDateTime timestamp) { - return timestamp.plusSeconds(1); + private OffsetDateTime usageEndExclusive(ExtractedCardVehicleUsageInterval usage, OffsetDateTime fallbackExclusiveEnd) { + if (usage.to() == null) { + return fallbackExclusiveEnd == null ? OffsetDateTime.MAX : fallbackExclusiveEnd; + } + return usage.to().plusSeconds(1); } private Element child(Element parent, String name) { diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java index 316423e..06f983f 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java @@ -52,7 +52,7 @@ public class DriverTimelineBuilder { return List.of(); } List sorted = rawIntervals.stream() - .filter(interval -> interval.from() != null && interval.to() != null && interval.to().isAfter(interval.from())) + .filter(interval -> interval.from() != null && (interval.to() == null || interval.to().isAfter(interval.from()))) .map(interval -> ResolvedVehicleUsageInterval.resolved( interval.intervalId(), interval.from(), @@ -65,7 +65,7 @@ public class DriverTimelineBuilder { List.of(interval.intervalId()) )) .sorted(Comparator.comparing(ResolvedVehicleUsageInterval::from) - .thenComparing(ResolvedVehicleUsageInterval::to)) + .thenComparing(ResolvedVehicleUsageInterval::to, Comparator.nullsLast(Comparator.naturalOrder()))) .toList(); if (sorted.isEmpty()) { return List.of(); @@ -81,7 +81,7 @@ public class DriverTimelineBuilder { current = ResolvedVehicleUsageInterval.resolved( current.intervalId() + "+" + next.intervalId(), current.from(), - max(current.to(), next.to()), + mergedTo(current.to(), next.to()), current.odometerBeginKm(), next.odometerEndKm() != null ? next.odometerEndKm() : current.odometerEndKm(), current.registrationKey(), @@ -102,7 +102,7 @@ public class DriverTimelineBuilder { private boolean canMerge(ResolvedVehicleUsageInterval left, ResolvedVehicleUsageInterval right) { return Objects.equals(left.registrationKey(), right.registrationKey()) && Objects.equals(left.vehicleKey(), right.vehicleKey()) - && !right.from().isAfter(left.to().plusSeconds(1)); + && !right.from().isAfter(mergeBoundary(left.to())); } private List resolveActivities( @@ -209,4 +209,15 @@ public class DriverTimelineBuilder { } return left.isAfter(right) ? left : right; } + + private OffsetDateTime mergedTo(OffsetDateTime left, OffsetDateTime right) { + if (left == null || right == null) { + return null; + } + return max(left, right); + } + + private OffsetDateTime mergeBoundary(OffsetDateTime endInclusive) { + return endInclusive == null ? OffsetDateTime.MAX : endInclusive.plusSeconds(1); + } } diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java index 2bd8da1..f703272 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java @@ -103,15 +103,7 @@ public class VehicleUnitXmlExtractionService { continue; } OffsetDateTime to = offsetDateTime(text(record, "cardWithdrawalTime")); - if (to == null) { - to = vehicleContext.defaultOpenIntervalEnd(); - sessionWarnings.add(new ExtractionWarning( - "OPEN_VU_CARD_INTERVAL", - "Vehicle-unit insertion/withdrawal record has no withdrawal time; interval was closed using the VU downloadable-period end.", - path - )); - } - if (to == null || to.isBefore(from)) { + if (to != null && to.isBefore(from)) { sessionWarnings.add(new ExtractionWarning( "INVALID_VU_CARD_INTERVAL", "Vehicle-unit insertion/withdrawal record has an invalid interval range.", @@ -288,11 +280,9 @@ public class VehicleUnitXmlExtractionService { )); } parsedChanges.sort(Comparator.comparing(ActivityChange::from)); - for (int i = 0; i < parsedChanges.size(); i++) { + for (int i = 0; i + 1 < parsedChanges.size(); i++) { ActivityChange current = parsedChanges.get(i); - OffsetDateTime to = i + 1 < parsedChanges.size() - ? parsedChanges.get(i + 1).from() - : date.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC); + OffsetDateTime to = parsedChanges.get(i + 1).from(); if (!current.from().isBefore(to)) { continue; } @@ -898,7 +888,7 @@ public class VehicleUnitXmlExtractionService { String rawRecordPath ) { private OffsetDateTime endExclusive() { - return to.plusSeconds(1); + return to == null ? OffsetDateTime.MAX : to.plusSeconds(1); } private boolean covers(OffsetDateTime timestamp) { diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java index 6852e4e..3c30e4c 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java @@ -45,12 +45,10 @@ class DriverCardXmlExtractionServiceTest { assertThat(driver.vehicleRegistrations()).hasSize(2); assertThat(driver.vehicles()).hasSize(2); assertThat(driver.cardVehicleUsageIntervals()).hasSize(2); - assertThat(driver.cardActivityIntervals()).hasSize(5); + assertThat(driver.cardActivityIntervals()).hasSize(3); assertThat(driver.cardActivityIntervals().get(0).registrationKey()).isEqualTo("12:W-12345A"); assertThat(driver.cardActivityIntervals().get(1).to()).isEqualTo(driver.cardVehicleUsageIntervals().get(0).to().plusSeconds(1)); assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B"); - assertThat(driver.cardActivityIntervals().get(3).registrationKey()).isEqualTo("12:W-54321B"); - assertThat(driver.cardActivityIntervals().get(4).registrationKey()).isNull(); } @Test @@ -74,9 +72,36 @@ class DriverCardXmlExtractionServiceTest { ); DriverExtractionSession driver = session.driversByKey().values().iterator().next(); - assertThat(driver.cardActivityIntervals()).hasSize(5); + assertThat(driver.cardActivityIntervals()).hasSize(3); assertThat(driver.cardActivityIntervals()) .extracting(interval -> interval.from().toString()) - .contains("2026-04-01T08:00Z", "2026-04-01T12:30Z"); + .contains("2026-04-01T08:00Z", "2026-04-01T12:00Z"); + } + + @Test + void keepsLastOpenVehicleUseRecordWithoutSynthesizingActivityTail() { + TachographFileSession session = service.extract( + parser.parse(DriverCardXmlSamples.driverCardXmlWithOpenVehicleUseRecord()), + new TachographFileSessionMetadata( + "default", + "legalrequirements-drivercard", + "sample", + "sample.ddd", + "abc", + 10, + "42", + "def", + true, + null + ), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + + DriverExtractionSession driver = session.driversByKey().values().iterator().next(); + assertThat(driver.cardVehicleUsageIntervals()).hasSize(2); + assertThat(driver.cardVehicleUsageIntervals().get(1).to()).isNull(); + assertThat(driver.cardActivityIntervals()).hasSize(3); + assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B"); } } diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java index 59bd4e7..34d653f 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java @@ -119,4 +119,9 @@ final class DriverCardXmlSamples { .replace("09:00:00Z", "09:00:00") .replace("12:30:00Z", "12:30:00"); } + + static String driverCardXmlWithOpenVehicleUseRecord() { + return validDriverCardXml() + .replace("2026-04-01T18:00:00Z\n", ""); + } } diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java index 82449f1..88db8fb 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java @@ -108,4 +108,70 @@ class DriverTimelineBuilderTest { assertThat(timeline.loadedTo()).isEqualTo(OffsetDateTime.parse("2026-05-02T08:00:00Z")); assertThat(timeline.warnings()).extracting(ExtractionWarning::code).containsExactly("W2", "W1"); } + + @Test + void mergesTouchingOpenEndedVehicleUsageInterval() { + DriverExtractionSession driver = new DriverExtractionSession( + "12:123", + null, + null, + List.of(), + List.of(), + List.of( + new ExtractedCardVehicleUsageInterval( + "CVU-1", + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-01T23:59:59Z"), + 100L, + 200L, + "12:REG-1", + "VIN-1", + "a" + ), + new ExtractedCardVehicleUsageInterval( + "CVU-2", + OffsetDateTime.parse("2026-05-02T00:00:00Z"), + null, + 201L, + null, + "12:REG-1", + "VIN-1", + "b" + ) + ), + List.of( + new ExtractedCardActivityInterval( + "ACT-1", + OffsetDateTime.parse("2026-05-02T08:00:00Z"), + OffsetDateTime.parse("2026-05-02T09:00:00Z"), + "WORK", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-1", + "VIN-1", + "c" + ) + ), + List.of(), + List.of() + ); + TachographFileSession session = new TachographFileSession( + UUID.randomUUID(), + new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null), + Map.of(driver.driverKey(), driver), + new ExtractionStats(1, 1, 2, 1, 1, 0), + List.of(), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + + ResolvedDriverTimeline timeline = builder.build(session, driver); + + assertThat(timeline.vehicleUsageIntervals()).hasSize(1); + assertThat(timeline.vehicleUsageIntervals().get(0).from()).isEqualTo(OffsetDateTime.parse("2026-05-01T00:00:00Z")); + assertThat(timeline.vehicleUsageIntervals().get(0).to()).isNull(); + assertThat(timeline.vehicleUsageIntervals().get(0).sourceIntervalIds()).containsExactly("CVU-1", "CVU-2"); + assertThat(timeline.loadedTo()).isEqualTo(OffsetDateTime.parse("2026-05-02T09:00:00Z")); + } } diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java index ec51109..e2e05ff 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java @@ -52,10 +52,9 @@ class VehicleUnitXmlExtractionServiceTest { assertThat(firstDriver.cardVehicleUsageIntervals()).hasSize(1); assertThat(firstDriver.cardVehicleUsageIntervals().get(0).from().toString()).isEqualTo("2026-04-01T08:00Z"); assertThat(firstDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-01T11:00Z"); - assertThat(firstDriver.cardActivityIntervals()).hasSize(3); + assertThat(firstDriver.cardActivityIntervals()).hasSize(2); assertThat(firstDriver.cardActivityIntervals().get(0).activityType()).isEqualTo("WORK"); assertThat(firstDriver.cardActivityIntervals().get(1).activityType()).isEqualTo("DRIVE"); - assertThat(firstDriver.cardActivityIntervals().get(2).to().toString()).isEqualTo("2026-04-01T11:00:01Z"); assertThat(firstDriver.supportEvents()).hasSize(1); assertThat(firstDriver.supportEvents().get(0).eventDomain()).isEqualTo("PLACE"); assertThat(firstDriver.supportEvents().get(0).eventType()).isEqualTo("BEGIN_DAILY_WORK_PERIOD"); @@ -63,17 +62,15 @@ class VehicleUnitXmlExtractionServiceTest { assertThat(firstDriver.supportEvents().get(0).latitude()).isNotNull(); assertThat(secondDriver.cardVehicleUsageIntervals()).hasSize(1); - assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-02T10:00Z"); - assertThat(secondDriver.cardActivityIntervals()).hasSize(2); + assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to()).isNull(); + assertThat(secondDriver.cardActivityIntervals()).hasSize(1); assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z"); - assertThat(secondDriver.cardActivityIntervals().get(1).to().toString()).isEqualTo("2026-04-02T10:00:01Z"); assertThat(secondDriver.supportEvents()).hasSize(2); assertThat(secondDriver.supportEvents()).extracting("eventDomain").containsExactly("POSITION", "SPECIFIC_CONDITION"); assertThat(secondDriver.supportEvents().get(0).latitude()).isNotNull(); assertThat(secondDriver.supportEvents().get(1).code()).isEqualTo("1"); - assertThat(session.warnings()).extracting("code") - .contains("OPEN_VU_CARD_INTERVAL", "VU_ACTIVITY_UNASSIGNED"); + assertThat(session.warnings()).isEmpty(); } @Test @@ -102,6 +99,32 @@ class VehicleUnitXmlExtractionServiceTest { assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z"); } + @Test + void keepsOpenVuCardUsageRecordWithoutClosingItAtDownloadPeriodEnd() throws Exception { + TachographFileSession session = service.extract( + new TachographXmlParser.ParsedTachographXml(document(VehicleUnitXmlSamples.vehicleUnitXml()), "VehicleUnit"), + new TachographFileSessionMetadata( + "default", + "legalrequirements-vehicleunit", + "sample-vu", + "sample-vu.ddd", + "abc", + 10, + "42", + "def", + false, + null + ), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + + DriverExtractionSession secondDriver = session.driversByKey().get("12:99999999999911"); + assertThat(secondDriver.cardVehicleUsageIntervals()).hasSize(1); + assertThat(secondDriver.cardVehicleUsageIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z"); + assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to()).isNull(); + } + private Document document(String xml) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(false);