From 7209a73d30feb1974bf52d4d0736982449e648f9 Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Tue, 12 May 2026 17:30:54 +0200 Subject: [PATCH] Handle tachograph activity times without offsets --- .../DriverCardXmlExtractionService.java | 150 ++++++++++-------- .../service/TachographTimeParser.java | 35 ++++ .../VehicleUnitXmlExtractionService.java | 6 +- .../DriverCardXmlExtractionServiceTest.java | 27 ++++ .../service/DriverCardXmlSamples.java | 7 + .../service/TachographTimeParserTest.java | 26 +++ .../VehicleUnitXmlExtractionServiceTest.java | 26 +++ .../service/VehicleUnitXmlSamples.java | 8 + 8 files changed, 218 insertions(+), 67 deletions(-) create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/service/TachographTimeParser.java create mode 100644 src/test/java/at/procon/eventhub/tachographfilesession/service/TachographTimeParserTest.java 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 c822ae7..e51052b 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java @@ -26,6 +26,7 @@ import java.util.UUID; import org.springframework.stereotype.Component; import org.w3c.dom.Document; import org.w3c.dom.Element; +import org.w3c.dom.Node; import org.w3c.dom.NodeList; @Component @@ -33,7 +34,6 @@ public class DriverCardXmlExtractionService { private final DriverKeyFactory driverKeyFactory; private final VehicleKeyFactory vehicleKeyFactory; - private final XmlExpressionEvaluator xml = new XmlExpressionEvaluator(); public DriverCardXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) { this.driverKeyFactory = driverKeyFactory; @@ -104,27 +104,28 @@ public class DriverCardXmlExtractionService { } private ExtractedDriverCard extractDriverCard(Document document, List warnings) { - Element identification = firstElement(document, "/DriverCard/Identification[1]"); + Element identification = child(document.getDocumentElement(), "Identification"); if (identification == null) { warnings.add(new ExtractionWarning("MISSING_IDENTIFICATION", "Driver card identification block is missing.", "/DriverCard")); return null; } - String cardNation = text(identification, "cardIdentification/cardIssuingMemberState"); + Element cardIdentification = child(identification, "cardIdentification"); + String cardNation = childText(cardIdentification, "cardIssuingMemberState"); String cardNumber = joinCardNumber(identification); - String authority = text(identification, "cardIdentification/cardIssuingAuthorityName/name"); + String authority = childText(child(cardIdentification, "cardIssuingAuthorityName"), "name"); return new ExtractedDriverCard( null, cardNation, cardNumber, authority, - offsetDateTime(text(identification, "cardIdentification/cardIssueDate")), - offsetDateTime(text(identification, "cardIdentification/cardValidityBegin")), - offsetDateTime(text(identification, "cardIdentification/cardExpiryDate")) + offsetDateTime(childText(cardIdentification, "cardIssueDate")), + offsetDateTime(childText(cardIdentification, "cardValidityBegin")), + offsetDateTime(childText(cardIdentification, "cardExpiryDate")) ); } private ExtractedDriver extractDriver(Document document, String driverKey, List warnings) { - Element identification = firstElement(document, "/DriverCard/Identification[1]"); + Element identification = child(document.getDocumentElement(), "Identification"); if (identification == null) { warnings.add(new ExtractionWarning("MISSING_DRIVER", "Driver holder identification block is missing.", "/DriverCard")); return new ExtractedDriver( @@ -139,17 +140,20 @@ public class DriverCardXmlExtractionService { null ); } - Element licenceInfo = firstElement(document, "/DriverCard/DrivingLicenseInfo[1]"); + Element driverCardHolderIdentification = child(identification, "driverCardHolderIdentification"); + Element cardHolderName = child(driverCardHolderIdentification, "cardHolderName"); + Element licenceInfo = child(document.getDocumentElement(), "DrivingLicenseInfo"); + Element cardDrivingLicenseInformation = child(licenceInfo, "cardDrivingLicenseInformation"); return new ExtractedDriver( driverKey, driverKeyFactory.createSourceDriverId(driverKey), - text(identification, "driverCardHolderIdentification/cardHolderName/holderSurname/name"), - text(identification, "driverCardHolderIdentification/cardHolderName/holderFirstNames/name"), - localDate(identification, "driverCardHolderIdentification/cardHolderBirthDate"), - text(identification, "driverCardHolderIdentification/cardHolderPreferredLanguage"), - text(licenceInfo, "cardDrivingLicenseInformation/drivingLicenceNumber"), - text(licenceInfo, "cardDrivingLicenseInformation/drivingLicenceIssuingNation"), - text(licenceInfo, "cardDrivingLicenseInformation/drivingLicenceIssuingAuthority/name") + childText(child(cardHolderName, "holderSurname"), "name"), + childText(child(cardHolderName, "holderFirstNames"), "name"), + localDate(child(driverCardHolderIdentification, "cardHolderBirthDate")), + childText(driverCardHolderIdentification, "cardHolderPreferredLanguage"), + childText(cardDrivingLicenseInformation, "drivingLicenceNumber"), + childText(cardDrivingLicenseInformation, "drivingLicenceIssuingNation"), + childText(child(cardDrivingLicenseInformation, "drivingLicenceIssuingAuthority"), "name") ); } @@ -159,15 +163,17 @@ public class DriverCardXmlExtractionService { Map vehiclesByKey, List warnings ) { - NodeList records = nodes(document, "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords"); - List intervals = new ArrayList<>(); - for (int i = 0; i < records.getLength(); i++) { - Element record = (Element) records.item(i); + Element cardVehiclesUsed = child(child(document.getDocumentElement(), "VehiclesUsed"), "cardVehiclesUsed"); + List records = children(cardVehiclesUsed, "cardVehicleRecords"); + List intervals = new ArrayList<>(records.size()); + for (int i = 0; i < records.size(); i++) { + Element record = records.get(i); String path = "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords[" + (i + 1) + "]"; - OffsetDateTime from = offsetDateTime(text(record, "vehicleFirstUse")); - OffsetDateTime to = offsetDateTime(text(record, "vehicleLastUse")); - String registrationNation = text(record, "vehicleRegistration/vehicleRegistrationNation"); - String registrationNumber = text(record, "vehicleRegistration/vehicleRegistrationNumber/vehicleRegNumber"); + OffsetDateTime from = offsetDateTime(childText(record, "vehicleFirstUse")); + OffsetDateTime to = offsetDateTime(childText(record, "vehicleLastUse")); + Element vehicleRegistration = child(record, "vehicleRegistration"); + String registrationNation = childText(vehicleRegistration, "vehicleRegistrationNation"); + String registrationNumber = childText(child(vehicleRegistration, "vehicleRegistrationNumber"), "vehicleRegNumber"); String registrationKey = vehicleKeyFactory.createRegistrationKey(registrationNation, registrationNumber); registrationsByKey.putIfAbsent( registrationKey, @@ -178,7 +184,7 @@ public class DriverCardXmlExtractionService { registrationNumber ) ); - String vin = text(record, "vehicleIdentificationNumber"); + String vin = childText(record, "vehicleIdentificationNumber"); String vehicleKey = vehicleKeyFactory.createVehicleKey(vin); if (vehicleKey != null) { vehiclesByKey.putIfAbsent( @@ -194,8 +200,8 @@ public class DriverCardXmlExtractionService { "CVU-" + (i + 1), from, to, - longValue(text(record, "vehicleOdometerBegin")), - longValue(text(record, "vehicleOdometerEnd")), + longValue(childText(record, "vehicleOdometerBegin")), + longValue(childText(record, "vehicleOdometerEnd")), registrationKey, vehicleKey, path @@ -206,33 +212,34 @@ public class DriverCardXmlExtractionService { } private List extractActivityIntervals(Document document, List warnings) { - NodeList dayRecords = nodes(document, "/DriverCard/DriverActivityData/cardDriverActivity/cardActivityDailyRecord"); - List intervals = new ArrayList<>(); + Element cardDriverActivity = child(child(document.getDocumentElement(), "DriverActivityData"), "cardDriverActivity"); + List dayRecords = children(cardDriverActivity, "cardActivityDailyRecord"); + List intervals = new ArrayList<>(dayRecords.size() * 8); int intervalNo = 0; - for (int dayIndex = 0; dayIndex < dayRecords.getLength(); dayIndex++) { - Element dayRecord = (Element) dayRecords.item(dayIndex); + for (int dayIndex = 0; dayIndex < dayRecords.size(); dayIndex++) { + Element dayRecord = dayRecords.get(dayIndex); String dayPath = "/DriverCard/DriverActivityData/cardDriverActivity/cardActivityDailyRecord[" + (dayIndex + 1) + "]"; - OffsetDateTime recordDate = offsetDateTime(text(dayRecord, "activityRecordDate")); + OffsetDateTime recordDate = offsetDateTime(childText(dayRecord, "activityRecordDate")); if (recordDate == null) { warnings.add(new ExtractionWarning("MISSING_ACTIVITY_RECORD_DATE", "Activity daily record has no activityRecordDate.", dayPath)); continue; } LocalDate date = recordDate.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate(); - NodeList changes = nodes(dayRecord, "activityChangeInfos"); - List parsedChanges = new ArrayList<>(); - for (int changeIndex = 0; changeIndex < changes.getLength(); changeIndex++) { - Element change = (Element) changes.item(changeIndex); - OffsetDateTime from = combine(date, text(change, "timeOfChange")); + List changes = children(dayRecord, "activityChangeInfos"); + List parsedChanges = new ArrayList<>(changes.size()); + for (int changeIndex = 0; changeIndex < changes.size(); changeIndex++) { + Element change = changes.get(changeIndex); + OffsetDateTime from = combine(date, childText(change, "timeOfChange")); if (from == null) { warnings.add(new ExtractionWarning("INVALID_ACTIVITY_CHANGE_TIME", "Activity change has invalid timeOfChange.", dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]")); continue; } parsedChanges.add(new ActivityChange( from, - normalizeActivity(text(change, "activity")), - normalizeToken(text(change, "slot")), - normalizeToken(text(change, "cardStatus")), - normalizeToken(text(change, "drivingStatus")), + normalizeActivity(childText(change, "activity")), + normalizeToken(childText(change, "slot")), + normalizeToken(childText(change, "cardStatus")), + normalizeToken(childText(change, "drivingStatus")), dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]" )); } @@ -351,20 +358,42 @@ public class DriverCardXmlExtractionService { return timestamp.plusSeconds(1); } - private Element firstElement(Object node, String expression) { - NodeList nodes = nodes(node, expression); - if (nodes.getLength() == 0) { + private Element child(Element parent, String name) { + if (parent == null) { return null; } - return (Element) nodes.item(0); + NodeList childNodes = parent.getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node child = childNodes.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE && name.equals(child.getNodeName())) { + return (Element) child; + } + } + return null; } - private NodeList nodes(Object node, String expression) { - return xml.nodes(node, expression); + private List children(Element parent, String name) { + if (parent == null) { + return List.of(); + } + NodeList childNodes = parent.getChildNodes(); + List children = new ArrayList<>(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node child = childNodes.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE && name.equals(child.getNodeName())) { + children.add((Element) child); + } + } + return children; } - private String text(Object node, String expression) { - return xml.text(node, expression); + private String childText(Element parent, String name) { + Element child = child(parent, name); + if (child == null) { + return null; + } + String value = child.getTextContent(); + return value == null || value.isBlank() ? null : value.trim(); } private OffsetDateTime offsetDateTime(String value) { @@ -374,14 +403,13 @@ public class DriverCardXmlExtractionService { return OffsetDateTime.parse(value.trim()).withOffsetSameInstant(ZoneOffset.UTC); } - private LocalDate localDate(Element element, String expression) { - Element dateElement = firstElement(element, expression); + private LocalDate localDate(Element dateElement) { if (dateElement == null) { return null; } - String year = text(dateElement, "year"); - String month = text(dateElement, "month"); - String day = text(dateElement, "day"); + String year = childText(dateElement, "year"); + String month = childText(dateElement, "month"); + String day = childText(dateElement, "day"); if (year == null || month == null || day == null) { return null; } @@ -389,11 +417,7 @@ public class DriverCardXmlExtractionService { } private OffsetDateTime combine(LocalDate date, String timeText) { - if (date == null || timeText == null || timeText.isBlank()) { - return null; - } - LocalTime time = java.time.OffsetTime.parse(timeText.trim()).withOffsetSameInstant(ZoneOffset.UTC).toLocalTime(); - return date.atTime(time).atOffset(ZoneOffset.UTC); + return TachographTimeParser.combineUtc(date, timeText); } private Long longValue(String value) { @@ -435,12 +459,14 @@ public class DriverCardXmlExtractionService { } private String joinCardNumber(Element identification) { - String driverIdentification = text(identification, "cardIdentification/cardNumber/driverIdentification"); + Element cardIdentification = child(identification, "cardIdentification"); + Element cardNumber = child(cardIdentification, "cardNumber"); + String driverIdentification = childText(cardNumber, "driverIdentification"); if (driverIdentification == null) { return null; } - String replacement = text(identification, "cardIdentification/cardNumber/cardReplacementIndex"); - String renewal = text(identification, "cardIdentification/cardNumber/cardRenewalIndex"); + String replacement = childText(cardNumber, "cardReplacementIndex"); + String renewal = childText(cardNumber, "cardRenewalIndex"); StringBuilder builder = new StringBuilder(driverIdentification); if (replacement != null) { builder.append(replacement); diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographTimeParser.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographTimeParser.java new file mode 100644 index 0000000..3310931 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographTimeParser.java @@ -0,0 +1,35 @@ +package at.procon.eventhub.tachographfilesession.service; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; + +final class TachographTimeParser { + + private TachographTimeParser() { + } + + static OffsetDateTime combineUtc(LocalDate date, String timeText) { + if (date == null || timeText == null || timeText.isBlank()) { + return null; + } + + String normalized = timeText.trim(); + try { + OffsetTime offsetTime = OffsetTime.parse(normalized); + return date.atTime(offsetTime.toLocalTime()) + .atOffset(offsetTime.getOffset()) + .withOffsetSameInstant(ZoneOffset.UTC); + } catch (DateTimeParseException ignored) { + } + + try { + return date.atTime(LocalTime.parse(normalized)).atOffset(ZoneOffset.UTC); + } catch (DateTimeParseException ignored) { + return null; + } + } +} 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 16c84db..2bd8da1 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java @@ -727,11 +727,7 @@ public class VehicleUnitXmlExtractionService { } private OffsetDateTime combine(LocalDate date, String timeText) { - if (date == null || timeText == null || timeText.isBlank()) { - return null; - } - LocalTime time = java.time.OffsetTime.parse(timeText.trim()).withOffsetSameInstant(ZoneOffset.UTC).toLocalTime(); - return date.atTime(time).atOffset(ZoneOffset.UTC); + return TachographTimeParser.combineUtc(date, timeText); } private String normalizeActivity(String value) { 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 1095730..6852e4e 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java @@ -52,4 +52,31 @@ class DriverCardXmlExtractionServiceTest { assertThat(driver.cardActivityIntervals().get(3).registrationKey()).isEqualTo("12:W-54321B"); assertThat(driver.cardActivityIntervals().get(4).registrationKey()).isNull(); } + + @Test + void extractsActivitiesWhenTimeOfChangeHasNoOffset() { + TachographFileSession session = service.extract( + parser.parse(DriverCardXmlSamples.validDriverCardXmlWithLocalActivityTimes()), + 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.cardActivityIntervals()).hasSize(5); + assertThat(driver.cardActivityIntervals()) + .extracting(interval -> interval.from().toString()) + .contains("2026-04-01T08:00Z", "2026-04-01T12:30Z"); + } } 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 7fd4df4..59bd4e7 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java @@ -112,4 +112,11 @@ final class DriverCardXmlSamples { """; } + + static String validDriverCardXmlWithLocalActivityTimes() { + return validDriverCardXml() + .replace("08:00:00Z", "08:00:00") + .replace("09:00:00Z", "09:00:00") + .replace("12:30:00Z", "12:30:00"); + } } diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographTimeParserTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographTimeParserTest.java new file mode 100644 index 0000000..faa069c --- /dev/null +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographTimeParserTest.java @@ -0,0 +1,26 @@ +package at.procon.eventhub.tachographfilesession.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class TachographTimeParserTest { + + @Test + void combinesPlainLocalTimeAsUtc() { + assertThat(TachographTimeParser.combineUtc(LocalDate.of(2026, 4, 1), "00:00:00")) + .hasToString("2026-04-01T00:00Z"); + } + + @Test + void preservesDateShiftWhenOffsetTimeCrossesUtcMidnight() { + assertThat(TachographTimeParser.combineUtc(LocalDate.of(2026, 4, 1), "00:30:00+02:00")) + .hasToString("2026-03-31T22:30Z"); + } + + @Test + void returnsNullForInvalidTimeText() { + assertThat(TachographTimeParser.combineUtc(LocalDate.of(2026, 4, 1), "not-a-time")).isNull(); + } +} 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 0d43165..ec51109 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java @@ -76,6 +76,32 @@ class VehicleUnitXmlExtractionServiceTest { .contains("OPEN_VU_CARD_INTERVAL", "VU_ACTIVITY_UNASSIGNED"); } + @Test + void extractsActivitiesWhenVuTimeOfChangeHasNoOffset() throws Exception { + TachographFileSession session = service.extract( + new TachographXmlParser.ParsedTachographXml(document(VehicleUnitXmlSamples.vehicleUnitXmlWithLocalActivityTimes()), "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 firstDriver = session.driversByKey().get("12:12345678901200"); + DriverExtractionSession secondDriver = session.driversByKey().get("12:99999999999911"); + assertThat(firstDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-01T08:00Z"); + assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z"); + } + private Document document(String xml) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(false); diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java index 64b8018..3ee068d 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java @@ -173,4 +173,12 @@ final class VehicleUnitXmlSamples { """; } + + static String vehicleUnitXmlWithLocalActivityTimes() { + return vehicleUnitXml() + .replace("08:00:00Z", "08:00:00") + .replace("09:00:00Z", "09:00:00") + .replace("11:00:00Z", "11:00:00") + .replace("07:30:00Z", "07:30:00"); + } }