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 1563b3c..ccaf78a 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java @@ -226,90 +226,122 @@ public class VehicleUnitXmlExtractionService { VehicleContext vehicleContext, List warnings ) { - NodeList dayRecords = nodes(document, "/VehicleUnit/Activities/vuActivityDailyData"); - if (dayRecords.getLength() == 0) { + NodeList activitySections = nodes(document, "/VehicleUnit/Activities"); + if (activitySections.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.", + int fallbackDayIndex = 0; + for (int activityIndex = 0; activityIndex < activitySections.getLength(); activityIndex++) { + Element activities = (Element) activitySections.item(activityIndex); + NodeList dayRecords = nodes(activities, "vuActivityDailyData"); + for (int dayIndex = 0; dayIndex < dayRecords.getLength(); dayIndex++) { + Element dayRecord = (Element) dayRecords.item(dayIndex); + String dayPath = dayRecords.getLength() == 1 + ? "/VehicleUnit/Activities[" + (activityIndex + 1) + "]/vuActivityDailyData" + : "/VehicleUnit/Activities[" + (activityIndex + 1) + "]/vuActivityDailyData[" + (dayIndex + 1) + "]"; + LocalDate date = resolveActivityDate( + activities, + vehicleContext, + activityIndex, + fallbackDayIndex + dayIndex, + dayPath, + warnings + ); + if (date == null) { + continue; + } + + 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) + "]" )); - 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() - : OffsetDateTime.of(date.plusDays(1), LocalTime.MIDNIGHT, ZoneOffset.UTC); - if (!current.from().isBefore(to)) { - continue; + 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() + : OffsetDateTime.of(date.plusDays(1), LocalTime.MIDNIGHT, 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() + )); } - intervalNo++; - intervals.add(new ExtractedCardActivityInterval( - "VUACT-" + intervalNo, - current.from(), - to, - current.activityType(), - current.slot(), - current.cardStatus(), - current.drivingStatus(), - null, - null, - current.rawRecordPath() - )); } + fallbackDayIndex += dayRecords.getLength(); } intervals.sort(Comparator.comparing(ExtractedCardActivityInterval::from)); return intervals; } + private LocalDate resolveActivityDate( + Element activities, + VehicleContext vehicleContext, + int activityIndex, + int fallbackDayIndex, + String dayPath, + List warnings + ) { + OffsetDateTime downloadTime = offsetDateTime(text(activities, "downloadTime")); + if (downloadTime != null) { + return downloadTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate(); + } + + LocalDate fallbackDate = vehicleContext.defaultActivityStartDate(); + if (fallbackDate == null) { + warnings.add(new ExtractionWarning( + "VU_ACTIVITY_DATE_INFERENCE_FAILED", + "Vehicle-unit activity daily data is present but neither Activities/downloadTime nor the VU downloadable period can provide its date.", + dayPath + )); + return null; + } + + LocalDate resolvedDate = fallbackDate.plusDays(fallbackDayIndex); + LocalDate maxDate = vehicleContext.defaultActivityEndDate(); + if (maxDate != null && resolvedDate.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[" + (activityIndex + 1) + "]" + )); + } + return resolvedDate; + } + private void assignActivityCoverage( List vuActivityIntervals, List vuCardIwIntervals, 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 02f4d39..ea85096 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java @@ -111,6 +111,35 @@ class VehicleUnitXmlExtractionServiceTest { assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z"); } + @Test + void usesActivitiesDownloadTimeAsVuActivityRecordDate() throws Exception { + TachographFileSession session = service.extract( + new TachographXmlParser.ParsedTachographXml(document(VehicleUnitXmlSamples.vehicleUnitXmlWithActivityDownloadTime()), "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 driver = session.driversByKey().get("12:12345678901200"); + assertThat(driver).isNotNull(); + assertThat(driver.cardActivityIntervals()).hasSize(2); + assertThat(driver.cardActivityIntervals().get(0).from()).isEqualTo(OffsetDateTime.parse("2026-05-25T08:00:00Z")); + assertThat(driver.cardActivityIntervals().get(1).from()).isEqualTo(OffsetDateTime.parse("2026-05-25T09:00:00Z")); + assertThat(driver.cardActivityIntervals().get(1).to()).isEqualTo(OffsetDateTime.parse("2026-05-26T00:00:00Z")); + assertThat(session.warnings()).isEmpty(); + } + @Test void keepsOpenVuCardUsageRecordWithoutClosingItAtDownloadPeriodEnd() throws Exception { TachographFileSession session = service.extract( 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 42c203f..5a42fa1 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java @@ -254,4 +254,64 @@ final class VehicleUnitXmlSamples { .replace("11:00:00Z", "11:00:00") .replace("07:30:00Z", "07:30:00"); } + + static String vehicleUnitXmlWithActivityDownloadTime() { + return """ + + + VINVU123456789012 + + 12 + W-1000V + + + 2026-05-20T00:00:00Z + 2026-05-20T23:59:59Z + + + + 2026-05-25T23:59:59Z + 3756 + + + + Muster + Max + + + DRIVER_CARD + 12 + + 123456789012 + 0 + 0 + + 3 + + 2026-05-25T08:00:00Z + 3700 + DRIVER + + + + 2 + + DRIVER + SINGLE + INSERTED + WORK + 08:00:00Z + + + DRIVER + SINGLE + INSERTED + DRIVING + 09:00:00Z + + + + + """; + } }