Fix VU activity date extraction from downloadTime

This commit is contained in:
trifonovt 2026-06-10 17:45:26 +02:00
parent 8a106c02c5
commit 08b3cbf073
3 changed files with 189 additions and 68 deletions

View File

@ -226,90 +226,122 @@ public class VehicleUnitXmlExtractionService {
VehicleContext vehicleContext, VehicleContext vehicleContext,
List<ExtractionWarning> warnings List<ExtractionWarning> warnings
) { ) {
NodeList dayRecords = nodes(document, "/VehicleUnit/Activities/vuActivityDailyData"); NodeList activitySections = nodes(document, "/VehicleUnit/Activities");
if (dayRecords.getLength() == 0) { if (activitySections.getLength() == 0) {
return List.of(); 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<ExtractedCardActivityInterval> intervals = new ArrayList<>(); List<ExtractedCardActivityInterval> intervals = new ArrayList<>();
int intervalNo = 0; int intervalNo = 0;
for (int dayIndex = 0; dayIndex < dayRecords.getLength(); dayIndex++) { int fallbackDayIndex = 0;
Element dayRecord = (Element) dayRecords.item(dayIndex); for (int activityIndex = 0; activityIndex < activitySections.getLength(); activityIndex++) {
LocalDate date = startDate.plusDays(dayIndex); Element activities = (Element) activitySections.item(activityIndex);
String dayPath = "/VehicleUnit/Activities[" + (dayIndex + 1) + "]/vuActivityDailyData"; NodeList dayRecords = nodes(activities, "vuActivityDailyData");
NodeList changes = nodes(dayRecord, "activityChangeInfos"); for (int dayIndex = 0; dayIndex < dayRecords.getLength(); dayIndex++) {
List<ActivityChange> parsedChanges = new ArrayList<>(); Element dayRecord = (Element) dayRecords.item(dayIndex);
for (int changeIndex = 0; changeIndex < changes.getLength(); changeIndex++) { String dayPath = dayRecords.getLength() == 1
Element change = (Element) changes.item(changeIndex); ? "/VehicleUnit/Activities[" + (activityIndex + 1) + "]/vuActivityDailyData"
OffsetDateTime from = combine(date, text(change, "timeOfChange")); : "/VehicleUnit/Activities[" + (activityIndex + 1) + "]/vuActivityDailyData[" + (dayIndex + 1) + "]";
if (from == null) { LocalDate date = resolveActivityDate(
warnings.add(new ExtractionWarning( activities,
"INVALID_VU_ACTIVITY_CHANGE_TIME", vehicleContext,
"Vehicle-unit activity change has invalid timeOfChange.", activityIndex,
fallbackDayIndex + dayIndex,
dayPath,
warnings
);
if (date == null) {
continue;
}
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"));
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) + "]" dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
)); ));
continue;
} }
parsedChanges.add(new ActivityChange( parsedChanges.sort(Comparator.comparing(ActivityChange::from));
from, for (int i = 0; i < parsedChanges.size(); i++) {
normalizeActivity(text(change, "activity")), ActivityChange current = parsedChanges.get(i);
normalizeToken(text(change, "slot")), OffsetDateTime to = i + 1 < parsedChanges.size()
normalizeToken(text(change, "cardStatus")), ? parsedChanges.get(i + 1).from()
normalizeToken(text(change, "drivingStatus")), : OffsetDateTime.of(date.plusDays(1), LocalTime.MIDNIGHT, ZoneOffset.UTC);
dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]" if (!current.from().isBefore(to)) {
)); continue;
} }
parsedChanges.sort(Comparator.comparing(ActivityChange::from)); intervalNo++;
for (int i = 0; i < parsedChanges.size(); i++) { intervals.add(new ExtractedCardActivityInterval(
ActivityChange current = parsedChanges.get(i); "VUACT-" + intervalNo,
OffsetDateTime to = i + 1 < parsedChanges.size() current.from(),
? parsedChanges.get(i + 1).from() to,
: OffsetDateTime.of(date.plusDays(1), LocalTime.MIDNIGHT, ZoneOffset.UTC); current.activityType(),
if (!current.from().isBefore(to)) { current.slot(),
continue; 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)); intervals.sort(Comparator.comparing(ExtractedCardActivityInterval::from));
return intervals; return intervals;
} }
private LocalDate resolveActivityDate(
Element activities,
VehicleContext vehicleContext,
int activityIndex,
int fallbackDayIndex,
String dayPath,
List<ExtractionWarning> 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( private void assignActivityCoverage(
List<ExtractedCardActivityInterval> vuActivityIntervals, List<ExtractedCardActivityInterval> vuActivityIntervals,
List<VuCardIwInterval> vuCardIwIntervals, List<VuCardIwInterval> vuCardIwIntervals,

View File

@ -111,6 +111,35 @@ class VehicleUnitXmlExtractionServiceTest {
assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z"); 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 @Test
void keepsOpenVuCardUsageRecordWithoutClosingItAtDownloadPeriodEnd() throws Exception { void keepsOpenVuCardUsageRecordWithoutClosingItAtDownloadPeriodEnd() throws Exception {
TachographFileSession session = service.extract( TachographFileSession session = service.extract(

View File

@ -254,4 +254,64 @@ final class VehicleUnitXmlSamples {
.replace("<timeOfChange>11:00:00Z</timeOfChange>", "<timeOfChange>11: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>"); .replace("<timeOfChange>07:30:00Z</timeOfChange>", "<timeOfChange>07:30:00</timeOfChange>");
} }
static String vehicleUnitXmlWithActivityDownloadTime() {
return """
<VehicleUnit>
<Overview>
<vehicleIdentificationNumber>VINVU123456789012</vehicleIdentificationNumber>
<vehicleRegistrationIdentification>
<vehicleRegistrationNation>12</vehicleRegistrationNation>
<vehicleRegistrationNumber><vehicleRegNumber>W-1000V</vehicleRegNumber></vehicleRegistrationNumber>
</vehicleRegistrationIdentification>
<vuDownloadablePeriod>
<minDownloadableTime>2026-05-20T00:00:00Z</minDownloadableTime>
<maxDownloadableTime>2026-05-20T23:59:59Z</maxDownloadableTime>
</vuDownloadablePeriod>
</Overview>
<Activities Generation="3">
<downloadTime>2026-05-25T23:59:59Z</downloadTime>
<odometerValueMidnight>3756</odometerValueMidnight>
<vuCardIWData>
<vuCardIWRecords>
<cardHolderName>
<holderSurname><name>Muster</name></holderSurname>
<holderFirstNames><name>Max</name></holderFirstNames>
</cardHolderName>
<fullCardNumber>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>123456789012</driverIdentification>
<cardReplacementIndex>0</cardReplacementIndex>
<cardRenewalIndex>0</cardRenewalIndex>
</cardNumber>
<generation>3</generation>
</fullCardNumber>
<cardInsertionTime>2026-05-25T08:00:00Z</cardInsertionTime>
<vehicleOdometerValueAtInsertion>3700</vehicleOdometerValueAtInsertion>
<cardSlotNumber>DRIVER</cardSlotNumber>
</vuCardIWRecords>
</vuCardIWData>
<vuActivityDailyData>
<noOfActivityChanges>2</noOfActivityChanges>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>WORK</activity>
<timeOfChange>08:00:00Z</timeOfChange>
</activityChangeInfos>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>DRIVING</activity>
<timeOfChange>09:00:00Z</timeOfChange>
</activityChangeInfos>
</vuActivityDailyData>
</Activities>
</VehicleUnit>
""";
}
} }