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 3b18e70..23a5c0c 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java @@ -12,6 +12,8 @@ import at.procon.eventhub.tachographfilesession.model.ExtractionWarning; import at.procon.eventhub.tachographfilesession.model.TachographFileSession; import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.ArrayList; @@ -19,6 +21,7 @@ import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.TreeSet; import java.util.UUID; import javax.xml.xpath.XPath; import javax.xml.xpath.XPathConstants; @@ -51,14 +54,7 @@ public class VehicleUnitXmlExtractionService { VehicleContext vehicleContext = extractVehicleContext(document, sessionWarnings); Map driversByKey = new LinkedHashMap<>(); - - if (nodes(document, "/VehicleUnit/Activities/vuActivityDailyData").getLength() > 0) { - sessionWarnings.add(new ExtractionWarning( - "VU_ACTIVITY_NOT_EXTRACTED", - "Vehicle-unit daily activity data is present but is not yet mapped into card activity intervals in this phase.", - "/VehicleUnit/Activities/vuActivityDailyData" - )); - } + List vuCardIwIntervals = new ArrayList<>(); NodeList records = nodes(document, "/VehicleUnit/Activities/vuCardIWData/vuCardIWRecords"); for (int i = 0; i < records.getLength(); i++) { @@ -135,6 +131,13 @@ public class VehicleUnitXmlExtractionService { vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(), path )); + vuCardIwIntervals.add(new VuCardIwInterval( + driverKey, + normalizeToken(text(record, "cardSlotNumber")), + from, + to, + path + )); } if (driversByKey.isEmpty()) { @@ -145,6 +148,9 @@ public class VehicleUnitXmlExtractionService { )); } + List vuActivityIntervals = extractActivityIntervals(document, vehicleContext, sessionWarnings); + assignActivityCoverage(vuActivityIntervals, vuCardIwIntervals, vehicleContext, driversByKey, sessionWarnings); + List allWarnings = new ArrayList<>(sessionWarnings); Map driverSessions = new LinkedHashMap<>(); int activityCount = 0; @@ -187,7 +193,7 @@ public class VehicleUnitXmlExtractionService { "Vehicle-unit XML does not contain an Overview block.", "/VehicleUnit" )); - return new VehicleContext(null, null, null); + return new VehicleContext(null, null, null, null); } String registrationNation = text(overview, "vehicleRegistrationIdentification/vehicleRegistrationNation"); @@ -217,7 +223,210 @@ public class VehicleUnitXmlExtractionService { } OffsetDateTime defaultEnd = offsetDateTime(text(overview, "vuDownloadablePeriod/maxDownloadableTime")); - return new VehicleContext(registration, vehicle, defaultEnd); + OffsetDateTime defaultStart = offsetDateTime(text(overview, "vuDownloadablePeriod/minDownloadableTime")); + return new VehicleContext(registration, vehicle, defaultStart, defaultEnd); + } + + private List extractActivityIntervals( + Document document, + VehicleContext vehicleContext, + List warnings + ) { + NodeList dayRecords = nodes(document, "/VehicleUnit/Activities/vuActivityDailyData"); + if (dayRecords.getLength() == 0) { + return List.of(); + } + + LocalDate startDate = vehicleContext.defaultActivityStartDate(); + if (startDate == null) { + warnings.add(new ExtractionWarning( + "VU_ACTIVITY_DATE_INFERENCE_FAILED", + "Vehicle-unit activity daily data is present but no base date could be inferred from the VU downloadable period.", + "/VehicleUnit/Activities/vuActivityDailyData" + )); + return List.of(); + } + + LocalDate maxDate = vehicleContext.defaultActivityEndDate(); + if (maxDate != null) { + LocalDate inferredEndDate = startDate.plusDays(dayRecords.getLength() - 1L); + if (inferredEndDate.isAfter(maxDate)) { + warnings.add(new ExtractionWarning( + "VU_ACTIVITY_DATE_INFERENCE_RANGE", + "Vehicle-unit activity daily records exceed the VU downloadable-period range when mapped by sequence order.", + "/VehicleUnit/Activities/vuActivityDailyData" + )); + } + } + + List intervals = new ArrayList<>(); + int intervalNo = 0; + for (int dayIndex = 0; dayIndex < dayRecords.getLength(); dayIndex++) { + Element dayRecord = (Element) dayRecords.item(dayIndex); + LocalDate date = startDate.plusDays(dayIndex); + String dayPath = "/VehicleUnit/Activities[" + (dayIndex + 1) + "]/vuActivityDailyData"; + 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")); + if (from == null) { + warnings.add(new ExtractionWarning( + "INVALID_VU_ACTIVITY_CHANGE_TIME", + "Vehicle-unit 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")), + dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]" + )); + } + parsedChanges.sort(Comparator.comparing(ActivityChange::from)); + for (int i = 0; i < 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); + if (!current.from().isBefore(to)) { + continue; + } + intervalNo++; + intervals.add(new ExtractedCardActivityInterval( + "VUACT-" + intervalNo, + current.from(), + to, + current.activityType(), + current.slot(), + current.cardStatus(), + current.drivingStatus(), + null, + null, + current.rawRecordPath() + )); + } + } + + intervals.sort(Comparator.comparing(ExtractedCardActivityInterval::from)); + return intervals; + } + + private void assignActivityCoverage( + List vuActivityIntervals, + List vuCardIwIntervals, + VehicleContext vehicleContext, + Map driversByKey, + List warnings + ) { + for (ExtractedCardActivityInterval interval : vuActivityIntervals) { + List segments = splitByDriverCoverage(interval, vuCardIwIntervals); + if (segments.isEmpty()) { + warnings.add(new ExtractionWarning( + "VU_ACTIVITY_UNASSIGNED", + "Vehicle-unit activity interval could not be assigned to a driver-card insertion/withdrawal interval.", + interval.rawRecordPath() + )); + continue; + } + if (isPartiallyCovered(interval, segments)) { + warnings.add(new ExtractionWarning( + "VU_ACTIVITY_UNASSIGNED", + "Vehicle-unit activity interval could only be partially assigned to driver-card insertion/withdrawal intervals.", + interval.rawRecordPath() + )); + } + + for (int i = 0; i < segments.size(); i++) { + ActivitySegment segment = segments.get(i); + DriverExtractionBuilder builder = driversByKey.get(segment.driverKey()); + if (builder == null) { + warnings.add(new ExtractionWarning( + "VU_ACTIVITY_DRIVER_MISSING", + "Vehicle-unit activity interval matched a driver key without an initialized driver session.", + interval.rawRecordPath() + )); + continue; + } + String intervalId = segments.size() == 1 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1); + builder.cardActivityIntervals.add(new ExtractedCardActivityInterval( + intervalId, + segment.from(), + segment.to(), + interval.activityType(), + interval.slot(), + interval.cardStatus(), + interval.drivingStatus(), + vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(), + vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(), + interval.rawRecordPath() + )); + } + } + } + + private boolean isPartiallyCovered(ExtractedCardActivityInterval interval, List segments) { + if (segments.isEmpty()) { + return true; + } + if (!segments.get(0).from().equals(interval.from())) { + return true; + } + if (!segments.get(segments.size() - 1).to().equals(interval.to())) { + return true; + } + for (int i = 1; i < segments.size(); i++) { + if (!segments.get(i - 1).to().equals(segments.get(i).from())) { + return true; + } + } + return false; + } + + private List splitByDriverCoverage( + ExtractedCardActivityInterval interval, + List vuCardIwIntervals + ) { + TreeSet cutPoints = new TreeSet<>(); + cutPoints.add(interval.from()); + cutPoints.add(interval.to()); + + List matchingIntervals = vuCardIwIntervals.stream() + .filter(iw -> iw.slot() == null || interval.slot() == null || iw.slot().equals(interval.slot())) + .filter(iw -> iw.overlaps(interval.from(), interval.to())) + .toList(); + + for (VuCardIwInterval iw : matchingIntervals) { + if (iw.from().isAfter(interval.from()) && iw.from().isBefore(interval.to())) { + cutPoints.add(iw.from()); + } + OffsetDateTime iwEndExclusive = iw.endExclusive(); + if (iwEndExclusive.isAfter(interval.from()) && iwEndExclusive.isBefore(interval.to())) { + cutPoints.add(iwEndExclusive); + } + } + + List orderedCutPoints = List.copyOf(cutPoints); + List segments = new ArrayList<>(); + for (int i = 0; i < orderedCutPoints.size() - 1; i++) { + OffsetDateTime segmentFrom = orderedCutPoints.get(i); + OffsetDateTime segmentTo = orderedCutPoints.get(i + 1); + if (!segmentFrom.isBefore(segmentTo)) { + continue; + } + VuCardIwInterval covering = matchingIntervals.stream() + .filter(iw -> iw.covers(segmentFrom)) + .findFirst() + .orElse(null); + if (covering != null) { + segments.add(new ActivitySegment(covering.driverKey(), segmentFrom, segmentTo)); + } + } + return segments; } private Element firstElement(Object node, String expression) { @@ -261,6 +470,35 @@ public class VehicleUnitXmlExtractionService { return Long.parseLong(value.trim()); } + 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); + } + + private String normalizeActivity(String value) { + String normalized = normalizeToken(value); + if (normalized == null) { + return "UNKNOWN_ACTIVITY"; + } + return switch (normalized) { + case "DRIVING", "DRIVE" -> "DRIVE"; + case "WORK" -> "WORK"; + case "AVAILABILITY", "AVAILABLE" -> "AVAILABILITY"; + case "BREAK_REST", "BREAK/REST", "REST" -> "BREAK_REST"; + default -> "UNKNOWN_ACTIVITY"; + }; + } + + private String normalizeToken(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim().toUpperCase().replace('-', '_').replace(' ', '_'); + } + private String joinCardNumber(Element node, String basePath) { String driverIdentification = text(node, basePath + "/driverIdentification"); if (driverIdentification == null) { @@ -358,7 +596,52 @@ public class VehicleUnitXmlExtractionService { private record VehicleContext( ExtractedVehicleRegistration registration, ExtractedVehicle vehicle, + OffsetDateTime defaultActivityStart, OffsetDateTime defaultOpenIntervalEnd + ) { + private LocalDate defaultActivityStartDate() { + return defaultActivityStart == null ? null : defaultActivityStart.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate(); + } + + private LocalDate defaultActivityEndDate() { + return defaultOpenIntervalEnd == null ? null : defaultOpenIntervalEnd.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate(); + } + } + + private record ActivityChange( + OffsetDateTime from, + String activityType, + String slot, + String cardStatus, + String drivingStatus, + String rawRecordPath + ) { + } + + private record VuCardIwInterval( + String driverKey, + String slot, + OffsetDateTime from, + OffsetDateTime to, + String rawRecordPath + ) { + private OffsetDateTime endExclusive() { + return to.plusSeconds(1); + } + + private boolean covers(OffsetDateTime timestamp) { + return !from.isAfter(timestamp) && timestamp.isBefore(endExclusive()); + } + + private boolean overlaps(OffsetDateTime rangeStart, OffsetDateTime rangeEnd) { + return endExclusive().isAfter(rangeStart) && from.isBefore(rangeEnd); + } + } + + private record ActivitySegment( + String driverKey, + OffsetDateTime from, + OffsetDateTime to ) { } } 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 a2544e4..7e57faf 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java @@ -52,12 +52,19 @@ 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().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(secondDriver.cardVehicleUsageIntervals()).hasSize(1); assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-02T10:00Z"); + assertThat(secondDriver.cardActivityIntervals()).hasSize(2); + 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(session.warnings()).extracting("code") - .contains("VU_ACTIVITY_NOT_EXTRACTED", "OPEN_VU_CARD_INTERVAL"); + .contains("OPEN_VU_CARD_INTERVAL", "VU_ACTIVITY_UNASSIGNED"); } private Document document(String xml) throws Exception { 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 fd56887..cfe92b1 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java @@ -15,6 +15,7 @@ final class VehicleUnitXmlSamples { W-1000V + 2026-04-01T00:00:00Z 2026-04-02T10:00:00Z @@ -64,7 +65,49 @@ final class VehicleUnitXmlSamples { MANUAL_ENTRIES - + + 3 + + DRIVER + SINGLE + INSERTED + WORK + 08:00:00Z + + + DRIVER + SINGLE + INSERTED + DRIVING + 09:00:00Z + + + DRIVER + SINGLE + INSERTED + BREAK/REST + 11:00:00Z + + + + + + 2 + + DRIVER + SINGLE + INSERTED + WORK + 07:30:00Z + + + DRIVER + SINGLE + INSERTED + DRIVING + 08:00:00Z + + """;