Keep open tachograph intervals without synthetic tails
This commit is contained in:
parent
7209a73d30
commit
9e6f8efb26
|
|
@ -31,7 +31,7 @@ public record ResolvedVehicleUsageInterval(
|
|||
intervalId,
|
||||
from,
|
||||
to,
|
||||
Duration.between(from, to).getSeconds(),
|
||||
to == null ? 0L : Duration.between(from, to).getSeconds(),
|
||||
odometerBeginKm,
|
||||
odometerEndKm,
|
||||
registrationKey,
|
||||
|
|
|
|||
|
|
@ -171,6 +171,14 @@ public class DriverCardXmlExtractionService {
|
|||
String path = "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords[" + (i + 1) + "]";
|
||||
OffsetDateTime from = offsetDateTime(childText(record, "vehicleFirstUse"));
|
||||
OffsetDateTime to = offsetDateTime(childText(record, "vehicleLastUse"));
|
||||
if (from == null) {
|
||||
warnings.add(new ExtractionWarning("MISSING_VEHICLE_FIRST_USE", "Driver-card vehicle record is missing vehicleFirstUse.", path));
|
||||
continue;
|
||||
}
|
||||
if (to != null && to.isBefore(from)) {
|
||||
warnings.add(new ExtractionWarning("INVALID_VEHICLE_USE_INTERVAL", "Driver-card vehicle record has an invalid first/last use range.", path));
|
||||
continue;
|
||||
}
|
||||
Element vehicleRegistration = child(record, "vehicleRegistration");
|
||||
String registrationNation = childText(vehicleRegistration, "vehicleRegistrationNation");
|
||||
String registrationNumber = childText(child(vehicleRegistration, "vehicleRegistrationNumber"), "vehicleRegNumber");
|
||||
|
|
@ -192,9 +200,8 @@ public class DriverCardXmlExtractionService {
|
|||
new ExtractedVehicle(vehicleKey, vehicleKeyFactory.createSourceVehicleId(vehicleKey), vin)
|
||||
);
|
||||
}
|
||||
if (from == null || to == null) {
|
||||
warnings.add(new ExtractionWarning("INCOMPLETE_VEHICLE_USAGE", "Vehicle usage interval is missing start or end timestamp.", path));
|
||||
continue;
|
||||
if (to == null) {
|
||||
warnings.add(new ExtractionWarning("OPEN_VEHICLE_USAGE", "Driver-card vehicle record has no vehicleLastUse and was kept open-ended.", path));
|
||||
}
|
||||
intervals.add(new ExtractedCardVehicleUsageInterval(
|
||||
"CVU-" + (i + 1),
|
||||
|
|
@ -244,11 +251,9 @@ public class DriverCardXmlExtractionService {
|
|||
));
|
||||
}
|
||||
parsedChanges.sort(Comparator.comparing(ActivityChange::from));
|
||||
for (int i = 0; i < parsedChanges.size(); i++) {
|
||||
for (int i = 0; i + 1 < 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);
|
||||
OffsetDateTime to = parsedChanges.get(i + 1).from();
|
||||
if (!current.from().isBefore(to)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -279,7 +284,7 @@ public class DriverCardXmlExtractionService {
|
|||
int usageStartIndex = 0;
|
||||
for (ExtractedCardActivityInterval interval : activityIntervals) {
|
||||
while (usageStartIndex < vehicleUsageIntervals.size()
|
||||
&& !endExclusive(vehicleUsageIntervals.get(usageStartIndex).to()).isAfter(interval.from())) {
|
||||
&& !usageEndExclusive(vehicleUsageIntervals.get(usageStartIndex), interval.to()).isAfter(interval.from())) {
|
||||
usageStartIndex++;
|
||||
}
|
||||
|
||||
|
|
@ -309,7 +314,7 @@ public class DriverCardXmlExtractionService {
|
|||
if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) {
|
||||
cutPoints.add(usage.from());
|
||||
}
|
||||
OffsetDateTime usageEndExclusive = endExclusive(usage.to());
|
||||
OffsetDateTime usageEndExclusive = usageEndExclusive(usage, interval.to());
|
||||
if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) {
|
||||
cutPoints.add(usageEndExclusive);
|
||||
}
|
||||
|
|
@ -326,7 +331,7 @@ public class DriverCardXmlExtractionService {
|
|||
}
|
||||
|
||||
while (coverageIndex < overlappingUsages.size()
|
||||
&& !endExclusive(overlappingUsages.get(coverageIndex).to()).isAfter(segmentFrom)) {
|
||||
&& !usageEndExclusive(overlappingUsages.get(coverageIndex), interval.to()).isAfter(segmentFrom)) {
|
||||
coverageIndex++;
|
||||
}
|
||||
ExtractedCardVehicleUsageInterval covering = coverageIndex < overlappingUsages.size()
|
||||
|
|
@ -351,11 +356,14 @@ public class DriverCardXmlExtractionService {
|
|||
}
|
||||
|
||||
private boolean covers(ExtractedCardVehicleUsageInterval usage, OffsetDateTime timestamp) {
|
||||
return !usage.from().isAfter(timestamp) && timestamp.isBefore(endExclusive(usage.to()));
|
||||
return !usage.from().isAfter(timestamp) && timestamp.isBefore(usageEndExclusive(usage, null));
|
||||
}
|
||||
|
||||
private OffsetDateTime endExclusive(OffsetDateTime timestamp) {
|
||||
return timestamp.plusSeconds(1);
|
||||
private OffsetDateTime usageEndExclusive(ExtractedCardVehicleUsageInterval usage, OffsetDateTime fallbackExclusiveEnd) {
|
||||
if (usage.to() == null) {
|
||||
return fallbackExclusiveEnd == null ? OffsetDateTime.MAX : fallbackExclusiveEnd;
|
||||
}
|
||||
return usage.to().plusSeconds(1);
|
||||
}
|
||||
|
||||
private Element child(Element parent, String name) {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public class DriverTimelineBuilder {
|
|||
return List.of();
|
||||
}
|
||||
List<ResolvedVehicleUsageInterval> sorted = rawIntervals.stream()
|
||||
.filter(interval -> interval.from() != null && interval.to() != null && interval.to().isAfter(interval.from()))
|
||||
.filter(interval -> interval.from() != null && (interval.to() == null || interval.to().isAfter(interval.from())))
|
||||
.map(interval -> ResolvedVehicleUsageInterval.resolved(
|
||||
interval.intervalId(),
|
||||
interval.from(),
|
||||
|
|
@ -65,7 +65,7 @@ public class DriverTimelineBuilder {
|
|||
List.of(interval.intervalId())
|
||||
))
|
||||
.sorted(Comparator.comparing(ResolvedVehicleUsageInterval::from)
|
||||
.thenComparing(ResolvedVehicleUsageInterval::to))
|
||||
.thenComparing(ResolvedVehicleUsageInterval::to, Comparator.nullsLast(Comparator.naturalOrder())))
|
||||
.toList();
|
||||
if (sorted.isEmpty()) {
|
||||
return List.of();
|
||||
|
|
@ -81,7 +81,7 @@ public class DriverTimelineBuilder {
|
|||
current = ResolvedVehicleUsageInterval.resolved(
|
||||
current.intervalId() + "+" + next.intervalId(),
|
||||
current.from(),
|
||||
max(current.to(), next.to()),
|
||||
mergedTo(current.to(), next.to()),
|
||||
current.odometerBeginKm(),
|
||||
next.odometerEndKm() != null ? next.odometerEndKm() : current.odometerEndKm(),
|
||||
current.registrationKey(),
|
||||
|
|
@ -102,7 +102,7 @@ public class DriverTimelineBuilder {
|
|||
private boolean canMerge(ResolvedVehicleUsageInterval left, ResolvedVehicleUsageInterval right) {
|
||||
return Objects.equals(left.registrationKey(), right.registrationKey())
|
||||
&& Objects.equals(left.vehicleKey(), right.vehicleKey())
|
||||
&& !right.from().isAfter(left.to().plusSeconds(1));
|
||||
&& !right.from().isAfter(mergeBoundary(left.to()));
|
||||
}
|
||||
|
||||
private List<ResolvedActivityInterval> resolveActivities(
|
||||
|
|
@ -209,4 +209,15 @@ public class DriverTimelineBuilder {
|
|||
}
|
||||
return left.isAfter(right) ? left : right;
|
||||
}
|
||||
|
||||
private OffsetDateTime mergedTo(OffsetDateTime left, OffsetDateTime right) {
|
||||
if (left == null || right == null) {
|
||||
return null;
|
||||
}
|
||||
return max(left, right);
|
||||
}
|
||||
|
||||
private OffsetDateTime mergeBoundary(OffsetDateTime endInclusive) {
|
||||
return endInclusive == null ? OffsetDateTime.MAX : endInclusive.plusSeconds(1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,15 +103,7 @@ public class VehicleUnitXmlExtractionService {
|
|||
continue;
|
||||
}
|
||||
OffsetDateTime to = offsetDateTime(text(record, "cardWithdrawalTime"));
|
||||
if (to == null) {
|
||||
to = vehicleContext.defaultOpenIntervalEnd();
|
||||
sessionWarnings.add(new ExtractionWarning(
|
||||
"OPEN_VU_CARD_INTERVAL",
|
||||
"Vehicle-unit insertion/withdrawal record has no withdrawal time; interval was closed using the VU downloadable-period end.",
|
||||
path
|
||||
));
|
||||
}
|
||||
if (to == null || to.isBefore(from)) {
|
||||
if (to != null && to.isBefore(from)) {
|
||||
sessionWarnings.add(new ExtractionWarning(
|
||||
"INVALID_VU_CARD_INTERVAL",
|
||||
"Vehicle-unit insertion/withdrawal record has an invalid interval range.",
|
||||
|
|
@ -288,11 +280,9 @@ public class VehicleUnitXmlExtractionService {
|
|||
));
|
||||
}
|
||||
parsedChanges.sort(Comparator.comparing(ActivityChange::from));
|
||||
for (int i = 0; i < parsedChanges.size(); i++) {
|
||||
for (int i = 0; i + 1 < 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);
|
||||
OffsetDateTime to = parsedChanges.get(i + 1).from();
|
||||
if (!current.from().isBefore(to)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -898,7 +888,7 @@ public class VehicleUnitXmlExtractionService {
|
|||
String rawRecordPath
|
||||
) {
|
||||
private OffsetDateTime endExclusive() {
|
||||
return to.plusSeconds(1);
|
||||
return to == null ? OffsetDateTime.MAX : to.plusSeconds(1);
|
||||
}
|
||||
|
||||
private boolean covers(OffsetDateTime timestamp) {
|
||||
|
|
|
|||
|
|
@ -45,12 +45,10 @@ class DriverCardXmlExtractionServiceTest {
|
|||
assertThat(driver.vehicleRegistrations()).hasSize(2);
|
||||
assertThat(driver.vehicles()).hasSize(2);
|
||||
assertThat(driver.cardVehicleUsageIntervals()).hasSize(2);
|
||||
assertThat(driver.cardActivityIntervals()).hasSize(5);
|
||||
assertThat(driver.cardActivityIntervals()).hasSize(3);
|
||||
assertThat(driver.cardActivityIntervals().get(0).registrationKey()).isEqualTo("12:W-12345A");
|
||||
assertThat(driver.cardActivityIntervals().get(1).to()).isEqualTo(driver.cardVehicleUsageIntervals().get(0).to().plusSeconds(1));
|
||||
assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B");
|
||||
assertThat(driver.cardActivityIntervals().get(3).registrationKey()).isEqualTo("12:W-54321B");
|
||||
assertThat(driver.cardActivityIntervals().get(4).registrationKey()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -74,9 +72,36 @@ class DriverCardXmlExtractionServiceTest {
|
|||
);
|
||||
|
||||
DriverExtractionSession driver = session.driversByKey().values().iterator().next();
|
||||
assertThat(driver.cardActivityIntervals()).hasSize(5);
|
||||
assertThat(driver.cardActivityIntervals()).hasSize(3);
|
||||
assertThat(driver.cardActivityIntervals())
|
||||
.extracting(interval -> interval.from().toString())
|
||||
.contains("2026-04-01T08:00Z", "2026-04-01T12:30Z");
|
||||
.contains("2026-04-01T08:00Z", "2026-04-01T12:00Z");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsLastOpenVehicleUseRecordWithoutSynthesizingActivityTail() {
|
||||
TachographFileSession session = service.extract(
|
||||
parser.parse(DriverCardXmlSamples.driverCardXmlWithOpenVehicleUseRecord()),
|
||||
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.cardVehicleUsageIntervals()).hasSize(2);
|
||||
assertThat(driver.cardVehicleUsageIntervals().get(1).to()).isNull();
|
||||
assertThat(driver.cardActivityIntervals()).hasSize(3);
|
||||
assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,4 +119,9 @@ final class DriverCardXmlSamples {
|
|||
.replace("<timeOfChange>09:00:00Z</timeOfChange>", "<timeOfChange>09:00:00</timeOfChange>")
|
||||
.replace("<timeOfChange>12:30:00Z</timeOfChange>", "<timeOfChange>12:30:00</timeOfChange>");
|
||||
}
|
||||
|
||||
static String driverCardXmlWithOpenVehicleUseRecord() {
|
||||
return validDriverCardXml()
|
||||
.replace("<vehicleLastUse>2026-04-01T18:00:00Z</vehicleLastUse>\n", "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,4 +108,70 @@ class DriverTimelineBuilderTest {
|
|||
assertThat(timeline.loadedTo()).isEqualTo(OffsetDateTime.parse("2026-05-02T08:00:00Z"));
|
||||
assertThat(timeline.warnings()).extracting(ExtractionWarning::code).containsExactly("W2", "W1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergesTouchingOpenEndedVehicleUsageInterval() {
|
||||
DriverExtractionSession driver = new DriverExtractionSession(
|
||||
"12:123",
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(
|
||||
new ExtractedCardVehicleUsageInterval(
|
||||
"CVU-1",
|
||||
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
|
||||
OffsetDateTime.parse("2026-05-01T23:59:59Z"),
|
||||
100L,
|
||||
200L,
|
||||
"12:REG-1",
|
||||
"VIN-1",
|
||||
"a"
|
||||
),
|
||||
new ExtractedCardVehicleUsageInterval(
|
||||
"CVU-2",
|
||||
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
|
||||
null,
|
||||
201L,
|
||||
null,
|
||||
"12:REG-1",
|
||||
"VIN-1",
|
||||
"b"
|
||||
)
|
||||
),
|
||||
List.of(
|
||||
new ExtractedCardActivityInterval(
|
||||
"ACT-1",
|
||||
OffsetDateTime.parse("2026-05-02T08:00:00Z"),
|
||||
OffsetDateTime.parse("2026-05-02T09:00:00Z"),
|
||||
"WORK",
|
||||
"DRIVER",
|
||||
"INSERTED",
|
||||
"SINGLE",
|
||||
"12:REG-1",
|
||||
"VIN-1",
|
||||
"c"
|
||||
)
|
||||
),
|
||||
List.of(),
|
||||
List.of()
|
||||
);
|
||||
TachographFileSession session = new TachographFileSession(
|
||||
UUID.randomUUID(),
|
||||
new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null),
|
||||
Map.of(driver.driverKey(), driver),
|
||||
new ExtractionStats(1, 1, 2, 1, 1, 0),
|
||||
List.of(),
|
||||
Instant.now(),
|
||||
Instant.now().plus(4, ChronoUnit.HOURS)
|
||||
);
|
||||
|
||||
ResolvedDriverTimeline timeline = builder.build(session, driver);
|
||||
|
||||
assertThat(timeline.vehicleUsageIntervals()).hasSize(1);
|
||||
assertThat(timeline.vehicleUsageIntervals().get(0).from()).isEqualTo(OffsetDateTime.parse("2026-05-01T00:00:00Z"));
|
||||
assertThat(timeline.vehicleUsageIntervals().get(0).to()).isNull();
|
||||
assertThat(timeline.vehicleUsageIntervals().get(0).sourceIntervalIds()).containsExactly("CVU-1", "CVU-2");
|
||||
assertThat(timeline.loadedTo()).isEqualTo(OffsetDateTime.parse("2026-05-02T09:00:00Z"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,10 +52,9 @@ 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()).hasSize(2);
|
||||
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(firstDriver.supportEvents()).hasSize(1);
|
||||
assertThat(firstDriver.supportEvents().get(0).eventDomain()).isEqualTo("PLACE");
|
||||
assertThat(firstDriver.supportEvents().get(0).eventType()).isEqualTo("BEGIN_DAILY_WORK_PERIOD");
|
||||
|
|
@ -63,17 +62,15 @@ class VehicleUnitXmlExtractionServiceTest {
|
|||
assertThat(firstDriver.supportEvents().get(0).latitude()).isNotNull();
|
||||
|
||||
assertThat(secondDriver.cardVehicleUsageIntervals()).hasSize(1);
|
||||
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-02T10:00Z");
|
||||
assertThat(secondDriver.cardActivityIntervals()).hasSize(2);
|
||||
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to()).isNull();
|
||||
assertThat(secondDriver.cardActivityIntervals()).hasSize(1);
|
||||
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(secondDriver.supportEvents()).hasSize(2);
|
||||
assertThat(secondDriver.supportEvents()).extracting("eventDomain").containsExactly("POSITION", "SPECIFIC_CONDITION");
|
||||
assertThat(secondDriver.supportEvents().get(0).latitude()).isNotNull();
|
||||
assertThat(secondDriver.supportEvents().get(1).code()).isEqualTo("1");
|
||||
|
||||
assertThat(session.warnings()).extracting("code")
|
||||
.contains("OPEN_VU_CARD_INTERVAL", "VU_ACTIVITY_UNASSIGNED");
|
||||
assertThat(session.warnings()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -102,6 +99,32 @@ class VehicleUnitXmlExtractionServiceTest {
|
|||
assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsOpenVuCardUsageRecordWithoutClosingItAtDownloadPeriodEnd() throws Exception {
|
||||
TachographFileSession session = service.extract(
|
||||
new TachographXmlParser.ParsedTachographXml(document(VehicleUnitXmlSamples.vehicleUnitXml()), "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 secondDriver = session.driversByKey().get("12:99999999999911");
|
||||
assertThat(secondDriver.cardVehicleUsageIntervals()).hasSize(1);
|
||||
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z");
|
||||
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to()).isNull();
|
||||
}
|
||||
|
||||
private Document document(String xml) throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(false);
|
||||
|
|
|
|||
Loading…
Reference in New Issue