Handle tachograph activity times without offsets

This commit is contained in:
trifonovt 2026-05-12 17:30:54 +02:00
parent a20d4c241e
commit 7209a73d30
8 changed files with 218 additions and 67 deletions

View File

@ -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<ExtractionWarning> 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<ExtractionWarning> 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<String, ExtractedVehicle> vehiclesByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords");
List<ExtractedCardVehicleUsageInterval> 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<Element> records = children(cardVehiclesUsed, "cardVehicleRecords");
List<ExtractedCardVehicleUsageInterval> 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<ExtractedCardActivityInterval> extractActivityIntervals(Document document, List<ExtractionWarning> warnings) {
NodeList dayRecords = nodes(document, "/DriverCard/DriverActivityData/cardDriverActivity/cardActivityDailyRecord");
List<ExtractedCardActivityInterval> intervals = new ArrayList<>();
Element cardDriverActivity = child(child(document.getDocumentElement(), "DriverActivityData"), "cardDriverActivity");
List<Element> dayRecords = children(cardDriverActivity, "cardActivityDailyRecord");
List<ExtractedCardActivityInterval> 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<ActivityChange> 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<Element> changes = children(dayRecord, "activityChangeInfos");
List<ActivityChange> 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<Element> children(Element parent, String name) {
if (parent == null) {
return List.of();
}
NodeList childNodes = parent.getChildNodes();
List<Element> 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);

View File

@ -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;
}
}
}

View File

@ -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) {

View File

@ -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");
}
}

View File

@ -112,4 +112,11 @@ final class DriverCardXmlSamples {
</DriverCard>
""";
}
static String validDriverCardXmlWithLocalActivityTimes() {
return validDriverCardXml()
.replace("<timeOfChange>08:00:00Z</timeOfChange>", "<timeOfChange>08:00:00</timeOfChange>")
.replace("<timeOfChange>09:00:00Z</timeOfChange>", "<timeOfChange>09:00:00</timeOfChange>")
.replace("<timeOfChange>12:30:00Z</timeOfChange>", "<timeOfChange>12:30:00</timeOfChange>");
}
}

View File

@ -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();
}
}

View File

@ -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);

View File

@ -173,4 +173,12 @@ final class VehicleUnitXmlSamples {
</VehicleUnit>
""";
}
static String vehicleUnitXmlWithLocalActivityTimes() {
return vehicleUnitXml()
.replace("<timeOfChange>08:00:00Z</timeOfChange>", "<timeOfChange>08:00:00</timeOfChange>")
.replace("<timeOfChange>09:00:00Z</timeOfChange>", "<timeOfChange>09:00:00</timeOfChange>")
.replace("<timeOfChange>11:00:00Z</timeOfChange>", "<timeOfChange>11:00:00</timeOfChange>")
.replace("<timeOfChange>07:30:00Z</timeOfChange>", "<timeOfChange>07:30:00</timeOfChange>");
}
}