Add and optimize tachograph session support events

This commit is contained in:
trifonovt 2026-05-20 10:20:04 +02:00
parent 2ded38a28a
commit dd0ccae290
12 changed files with 930 additions and 11 deletions

View File

@ -8,16 +8,22 @@ public record ExtractedSupportEvent(
OffsetDateTime occurredAt, OffsetDateTime occurredAt,
String eventDomain, String eventDomain,
String eventType, String eventType,
String eventLifecycle,
String slot, String slot,
String registrationKey, String registrationKey,
String vehicleKey, String vehicleKey,
String country, String country,
String region, String region,
String countryFrom,
String countryTo,
String operation,
BigDecimal latitude, BigDecimal latitude,
BigDecimal longitude, BigDecimal longitude,
String authenticationStatus, String authenticationStatus,
Long odometerKm, Long odometerKm,
String code, String code,
BigDecimal avgSpeedKmh,
BigDecimal maxSpeedKmh,
String rawRecordPath String rawRecordPath
) { ) {
} }

View File

@ -5,12 +5,14 @@ import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInter
import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriver; import at.procon.eventhub.tachographfilesession.model.ExtractedDriver;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard; import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle; import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration; import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats; import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning; import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession; import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime; import java.time.LocalTime;
@ -71,6 +73,7 @@ public class DriverCardXmlExtractionService {
extractVehicleUsageIntervals(document, registrationsByKey, vehiclesByKey, warnings); extractVehicleUsageIntervals(document, registrationsByKey, vehiclesByKey, warnings);
List<ExtractedCardActivityInterval> activityIntervals = List<ExtractedCardActivityInterval> activityIntervals =
assignVehicleCoverage(extractActivityIntervals(document, warnings), vehicleUsageIntervals); assignVehicleCoverage(extractActivityIntervals(document, warnings), vehicleUsageIntervals);
List<ExtractedSupportEvent> supportEvents = extractSupportEvents(document, vehicleUsageIntervals, warnings);
DriverExtractionSession driverSession = new DriverExtractionSession( DriverExtractionSession driverSession = new DriverExtractionSession(
driverKey, driverKey,
@ -80,7 +83,7 @@ public class DriverCardXmlExtractionService {
List.copyOf(vehiclesByKey.values()), List.copyOf(vehiclesByKey.values()),
List.copyOf(vehicleUsageIntervals), List.copyOf(vehicleUsageIntervals),
List.copyOf(activityIntervals), List.copyOf(activityIntervals),
List.of(), List.copyOf(supportEvents),
List.copyOf(warnings) List.copyOf(warnings)
); );
Map<String, DriverExtractionSession> driversByKey = Map.of(driverKey, driverSession); Map<String, DriverExtractionSession> driversByKey = Map.of(driverKey, driverSession);
@ -302,6 +305,284 @@ public class DriverCardXmlExtractionService {
return result; return result;
} }
private List<ExtractedSupportEvent> extractSupportEvents(
Document document,
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals,
List<ExtractionWarning> warnings
) {
VehicleUsageLookup vehicleUsageLookup = new VehicleUsageLookup(vehicleUsageIntervals);
List<ExtractedSupportEvent> supportEvents = new ArrayList<>();
Element root = document.getDocumentElement();
extractCardPlaceSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
extractCardGnssSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
extractCardSpecificConditionSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
extractCardBorderCrossingSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
extractCardLoadUnloadSupportEvents(root, vehicleUsageLookup, supportEvents, warnings);
supportEvents.sort(Comparator.comparing(ExtractedSupportEvent::occurredAt)
.thenComparing(ExtractedSupportEvent::eventDomain, Comparator.nullsLast(String::compareTo))
.thenComparing(ExtractedSupportEvent::eventId, Comparator.nullsLast(String::compareTo)));
return List.copyOf(supportEvents);
}
private void extractCardPlaceSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "Places");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element cardPlaceDailyWorkPeriod = child(sections.get(sectionIndex), "cardPlaceDailyWorkPeriod");
List<Element> records = children(cardPlaceDailyWorkPeriod, "placeRecords");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/Places[" + (sectionIndex + 1) + "]/cardPlaceDailyWorkPeriod/placeRecords[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(childText(record, "entryTime"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_PLACE_MISSING_TIME", "Driver-card place record is missing entryTime.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
Element gnss = child(record, "entryGnssPlaceRecord");
Element geoCoordinates = child(gnss, "geoCoordinates");
BigDecimal latitude = geoCoordinate(geoCoordinates, "latitude", true);
BigDecimal longitude = geoCoordinate(geoCoordinates, "longitude", false);
String authenticationStatus = normalizeToken(childText(gnss, "authenticationStatus"));
String entryType = childText(record, "entryTypeDailyWorkPeriod");
supportEvents.add(new ExtractedSupportEvent(
"CARDPLACE-" + (i + 1),
occurredAt,
"PLACE",
mapPlaceEntryType(entryType),
null,
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
childText(record, "dailyWorkPeriodCountry"),
childText(record, "dailyWorkPeriodRegion"),
null,
null,
null,
latitude,
longitude,
authenticationStatus,
longValue(childText(record, "vehicleOdometerValue")),
normalizeToken(entryType),
null,
null,
path
));
}
}
}
private void extractCardGnssSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "GnssPlaces");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element gnssAccumulatedDriving = child(sections.get(sectionIndex), "gnssAccumulatedDriving");
List<Element> records = children(gnssAccumulatedDriving, "gnssAccumulatedDrivingRecord");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/GnssPlaces[" + (sectionIndex + 1) + "]/gnssAccumulatedDriving/gnssAccumulatedDrivingRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(childText(record, "timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_GNSS_MISSING_TIME", "Driver-card GNSS record is missing timeStamp.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
Element gnss = child(record, "gnssPlaceRecord");
Element geoCoordinates = child(gnss, "geoCoordinates");
BigDecimal latitude = geoCoordinate(geoCoordinates, "latitude", true);
BigDecimal longitude = geoCoordinate(geoCoordinates, "longitude", false);
String authenticationStatus = normalizeToken(childText(gnss, "authenticationStatus"));
supportEvents.add(new ExtractedSupportEvent(
"CARDGNSS-" + (i + 1),
occurredAt,
"POSITION",
"POSITION_RECORDED",
"SNAPSHOT",
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
null,
null,
null,
null,
null,
latitude,
longitude,
authenticationStatus,
longValue(childText(record, "vehicleOdometerValue")),
null,
null,
null,
path
));
}
}
}
private void extractCardSpecificConditionSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "SpecificConditions");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element specificConditionData = child(sections.get(sectionIndex), "specificConditionData");
List<Element> records = children(specificConditionData, "specificConditionRecord");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/SpecificConditions[" + (sectionIndex + 1) + "]/specificConditionData/specificConditionRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(childText(record, "entryTime"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_SPECIFIC_CONDITION_MISSING_TIME", "Driver-card specific-condition record is missing entryTime.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
String conditionCode = normalizeToken(childText(record, "specificConditionType"));
String[] specificCondition = mapSpecificCondition(conditionCode);
supportEvents.add(new ExtractedSupportEvent(
"CARDSC-" + (i + 1),
occurredAt,
"SPECIFIC_CONDITION",
specificCondition[0],
specificCondition[1],
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
null,
null,
null,
null,
null,
null,
null,
null,
null,
conditionCode,
null,
null,
path
));
}
}
}
private void extractCardBorderCrossingSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "BorderCrossings");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element cardBorderCrossings = child(sections.get(sectionIndex), "cardBorderCrossings");
List<Element> records = children(cardBorderCrossings, "cardBorderCrossingRecord");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/BorderCrossings[" + (sectionIndex + 1) + "]/cardBorderCrossings/cardBorderCrossingRecord[" + (i + 1) + "]";
Element gnss = child(record, "gnssPlaceAuthRecord");
OffsetDateTime occurredAt = offsetDateTime(childText(gnss, "timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_BORDER_CROSSING_MISSING_TIME", "Driver-card border-crossing record is missing gnssPlaceAuthRecord/timeStamp.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
Element geoCoordinates = child(gnss, "geoCoordinates");
BigDecimal latitude = geoCoordinate(geoCoordinates, "latitude", true);
BigDecimal longitude = geoCoordinate(geoCoordinates, "longitude", false);
String authenticationStatus = normalizeToken(childText(gnss, "authenticationStatus"));
supportEvents.add(new ExtractedSupportEvent(
"CARDBORDER-" + (i + 1),
occurredAt,
"BORDER_CROSSING",
"BORDER_OUTBOUND",
"OUTBOUND",
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
null,
null,
childText(record, "countryLeft"),
childText(record, "countryEntered"),
null,
latitude,
longitude,
authenticationStatus,
longValue(childText(record, "vehicleOdometerValue")),
null,
null,
null,
path
));
}
}
}
private void extractCardLoadUnloadSupportEvents(
Element root,
VehicleUsageLookup vehicleUsageLookup,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
List<Element> sections = children(root, "LoadUnloadOperations");
for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
Element cardLoadUnloadOperations = child(sections.get(sectionIndex), "cardLoadUnloadOperations");
List<Element> records = children(cardLoadUnloadOperations, "cardLoadUnloadRecord");
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/LoadUnloadOperations[" + (sectionIndex + 1) + "]/cardLoadUnloadOperations/cardLoadUnloadRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(childText(record, "timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("CARD_LOAD_UNLOAD_MISSING_TIME", "Driver-card load/unload record is missing timeStamp.", path));
continue;
}
ExtractedCardVehicleUsageInterval usage = vehicleUsageLookup.resolve(occurredAt);
Element gnss = child(record, "gnssPlaceAuthRecord");
Element geoCoordinates = child(gnss, "geoCoordinates");
BigDecimal latitude = geoCoordinate(geoCoordinates, "latitude", true);
BigDecimal longitude = geoCoordinate(geoCoordinates, "longitude", false);
String authenticationStatus = normalizeToken(childText(gnss, "authenticationStatus"));
String operation = mapOperation(childText(record, "operationType"));
supportEvents.add(new ExtractedSupportEvent(
"CARDLOAD-" + (i + 1),
occurredAt,
"LOAD_UNLOAD",
operation,
"SNAPSHOT",
null,
usage == null ? null : usage.registrationKey(),
usage == null ? null : usage.vehicleKey(),
null,
null,
null,
null,
operation,
latitude,
longitude,
authenticationStatus,
longValue(childText(record, "vehicleOdometerValue")),
normalizeToken(childText(record, "operationType")),
null,
null,
path
));
}
}
}
private List<ExtractedCardActivityInterval> splitByVehicleCoverage( private List<ExtractedCardActivityInterval> splitByVehicleCoverage(
ExtractedCardActivityInterval interval, ExtractedCardActivityInterval interval,
List<ExtractedCardVehicleUsageInterval> overlappingUsages List<ExtractedCardVehicleUsageInterval> overlappingUsages
@ -366,6 +647,26 @@ public class DriverCardXmlExtractionService {
return usage.to().plusSeconds(1); return usage.to().plusSeconds(1);
} }
private BigDecimal geoCoordinate(Element geoCoordinates, String component, boolean latitude) {
String value = childText(geoCoordinates, component);
if (value == null || value.isBlank()) {
return null;
}
BigDecimal raw = new BigDecimal(value);
BigDecimal abs = raw.abs();
BigDecimal degreeThreshold = BigDecimal.valueOf(latitude ? 90D : 180D);
if (abs.compareTo(degreeThreshold) <= 0) {
return raw;
}
BigDecimal sign = raw.signum() < 0 ? BigDecimal.valueOf(-1L) : BigDecimal.ONE;
BigDecimal absolute = raw.abs();
BigDecimal[] degreeAndRemainder = absolute.divideAndRemainder(BigDecimal.valueOf(1000L));
BigDecimal degrees = degreeAndRemainder[0];
BigDecimal minutes = degreeAndRemainder[1].divide(BigDecimal.TEN);
BigDecimal decimalDegrees = degrees.add(minutes.divide(BigDecimal.valueOf(60L), 8, java.math.RoundingMode.HALF_UP));
return decimalDegrees.multiply(sign);
}
private Element child(Element parent, String name) { private Element child(Element parent, String name) {
if (parent == null) { if (parent == null) {
return null; return null;
@ -456,6 +757,50 @@ public class DriverCardXmlExtractionService {
return value.trim().toUpperCase().replace('-', '_').replace(' ', '_'); return value.trim().toUpperCase().replace('-', '_').replace(' ', '_');
} }
private String mapPlaceEntryType(String entryType) {
String normalized = normalizeToken(entryType);
if (normalized == null) {
return "DAILY_WORK_PERIOD_PLACE";
}
return switch (normalized) {
case "0" -> "BEGIN_DAILY_WORK_PERIOD";
case "1" -> "END_DAILY_WORK_PERIOD";
case "2" -> "BEGIN_MANUAL_DAILY_WORK_PERIOD";
case "3" -> "END_MANUAL_DAILY_WORK_PERIOD";
case "4" -> "BEGIN_ASSUMED_DAILY_WORK_PERIOD";
case "5" -> "END_ASSUMED_DAILY_WORK_PERIOD";
case "6" -> "BEGIN_GNSS_DAILY_WORK_PERIOD";
case "7" -> "END_GNSS_DAILY_WORK_PERIOD";
default -> "DAILY_WORK_PERIOD_PLACE";
};
}
private String[] mapSpecificCondition(String conditionCode) {
String normalized = normalizeToken(conditionCode);
if (normalized == null) {
return new String[]{"UNKNOWN_EVENT", "SNAPSHOT"};
}
return switch (normalized) {
case "1" -> new String[]{"OUT", "BEGIN"};
case "2" -> new String[]{"OUT", "END"};
case "3" -> new String[]{"FERRY_TRAIN", "BEGIN"};
case "4" -> new String[]{"FERRY_TRAIN", "END"};
default -> new String[]{"UNKNOWN_EVENT", "SNAPSHOT"};
};
}
private String mapOperation(String operationType) {
String normalized = normalizeToken(operationType);
if (normalized == null) {
return "LOAD_UNLOAD";
}
return switch (normalized) {
case "1", "LOAD" -> "LOAD";
case "2", "UNLOAD" -> "UNLOAD";
default -> "LOAD_UNLOAD";
};
}
private record ActivityChange( private record ActivityChange(
OffsetDateTime from, OffsetDateTime from,
String activityType, String activityType,
@ -484,4 +829,42 @@ public class DriverCardXmlExtractionService {
} }
return builder.toString(); return builder.toString();
} }
private final class VehicleUsageLookup {
private final List<ExtractedCardVehicleUsageInterval> intervals;
private VehicleUsageLookup(List<ExtractedCardVehicleUsageInterval> intervals) {
this.intervals = intervals == null ? List.of() : intervals;
}
private ExtractedCardVehicleUsageInterval resolve(OffsetDateTime occurredAt) {
if (occurredAt == null || intervals.isEmpty()) {
return null;
}
int low = 0;
int high = intervals.size() - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
ExtractedCardVehicleUsageInterval interval = intervals.get(mid);
if (interval.from().isAfter(occurredAt)) {
high = mid - 1;
continue;
}
if (covers(interval, occurredAt)) {
return interval;
}
low = mid + 1;
}
for (int i = Math.min(high, intervals.size() - 1); i >= 0; i--) {
ExtractedCardVehicleUsageInterval interval = intervals.get(i);
if (covers(interval, occurredAt)) {
return interval;
}
if (interval.from().isBefore(occurredAt) && usageEndExclusive(interval, null).isBefore(occurredAt)) {
break;
}
}
return null;
}
}
} }

View File

@ -152,16 +152,22 @@ public class EventBackedDriverTimelineBuilder {
event.occurredAt(), event.occurredAt(),
event.eventDomain().name(), event.eventDomain().name(),
text(raw, "supportEventType") == null ? event.eventType().name() : text(raw, "supportEventType"), text(raw, "supportEventType") == null ? event.eventType().name() : text(raw, "supportEventType"),
event.lifecycle().name(),
text(raw, "slot"), text(raw, "slot"),
text(raw, "registrationKey"), text(raw, "registrationKey"),
text(raw, "vehicleKey"), text(raw, "vehicleKey"),
text(raw, "country"), text(raw, "country"),
text(raw, "region"), text(raw, "region"),
text(raw, "countryFrom"),
text(raw, "countryTo"),
text(raw, "operation"),
latitude, latitude,
longitude, longitude,
text(raw, "authenticationStatus"), text(raw, "authenticationStatus"),
longValue(raw, "odometerKm"), longValue(raw, "odometerKm"),
text(raw, "code"), text(raw, "code"),
decimal(raw, "avgSpeedKmh"),
decimal(raw, "maxSpeedKmh"),
text(raw, "rawRecordPath") text(raw, "rawRecordPath")
)); ));
} }
@ -289,6 +295,21 @@ public class EventBackedDriverTimelineBuilder {
return value.isNumber() ? value.asLong() : Long.parseLong(value.asText()); return value.isNumber() ? value.asLong() : Long.parseLong(value.asText());
} }
private BigDecimal decimal(JsonNode node, String field) {
if (node == null || field == null) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
return value.decimalValue();
}
String text = value.asText(null);
return text == null || text.isBlank() ? null : new BigDecimal(text);
}
private List<String> stringList(JsonNode node, String field) { private List<String> stringList(JsonNode node, String field) {
if (node == null || field == null) { if (node == null || field == null) {
return List.of(); return List.of();

View File

@ -294,7 +294,7 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE
for (ExtractedSupportEvent supportEvent : supportEvents) { for (ExtractedSupportEvent supportEvent : supportEvents) {
EventDomain eventDomain = supportEventDomain(supportEvent.eventDomain()); EventDomain eventDomain = supportEventDomain(supportEvent.eventDomain());
EventType eventType = supportEventType(eventDomain, supportEvent.eventType(), supportEvent.code()); EventType eventType = supportEventType(eventDomain, supportEvent.eventType(), supportEvent.code());
EventLifecycle lifecycle = supportEventLifecycle(eventDomain, supportEvent.eventType()); EventLifecycle lifecycle = supportEventLifecycle(eventDomain, supportEvent.eventType(), supportEvent.eventLifecycle());
boolean manualEntry = isManualPlaceEvent(supportEvent.eventType()); boolean manualEntry = isManualPlaceEvent(supportEvent.eventType());
VehicleRefDto vehicleRef = vehicleRef( VehicleRefDto vehicleRef = vehicleRef(
supportEvent.registrationKey(), supportEvent.registrationKey(),
@ -312,9 +312,14 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE
raw.put("vehicleKey", supportEvent.vehicleKey()); raw.put("vehicleKey", supportEvent.vehicleKey());
raw.put("country", supportEvent.country()); raw.put("country", supportEvent.country());
raw.put("region", supportEvent.region()); raw.put("region", supportEvent.region());
raw.put("countryFrom", supportEvent.countryFrom());
raw.put("countryTo", supportEvent.countryTo());
raw.put("operation", supportEvent.operation());
raw.put("authenticationStatus", supportEvent.authenticationStatus()); raw.put("authenticationStatus", supportEvent.authenticationStatus());
raw.put("odometerKm", supportEvent.odometerKm()); raw.put("odometerKm", supportEvent.odometerKm());
raw.put("code", supportEvent.code()); raw.put("code", supportEvent.code());
raw.put("avgSpeedKmh", supportEvent.avgSpeedKmh());
raw.put("maxSpeedKmh", supportEvent.maxSpeedKmh());
raw.put("rawRecordPath", supportEvent.rawRecordPath()); raw.put("rawRecordPath", supportEvent.rawRecordPath());
events.add(event( events.add(event(
@ -509,12 +514,19 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE
return switch (normalizeToken(value)) { return switch (normalizeToken(value)) {
case "PLACE" -> EventDomain.PLACE; case "PLACE" -> EventDomain.PLACE;
case "POSITION" -> EventDomain.POSITION; case "POSITION" -> EventDomain.POSITION;
case "BORDER_CROSSING" -> EventDomain.BORDER_CROSSING;
case "LOAD_UNLOAD" -> EventDomain.LOAD_UNLOAD;
case "SPECIFIC_CONDITION" -> EventDomain.SPECIFIC_CONDITION; case "SPECIFIC_CONDITION" -> EventDomain.SPECIFIC_CONDITION;
case "SPEEDING" -> EventDomain.SPEEDING;
default -> EventDomain.TELEMATICS_DATA; default -> EventDomain.TELEMATICS_DATA;
}; };
} }
private EventType supportEventType(EventDomain eventDomain, String eventType, String code) { private EventType supportEventType(EventDomain eventDomain, String eventType, String code) {
EventType explicit = parseEnum(EventType.class, eventType, null);
if (explicit != null) {
return explicit;
}
return switch (eventDomain) { return switch (eventDomain) {
case PLACE -> EventType.WORKING_DAY_PLACE_RECORDED; case PLACE -> EventType.WORKING_DAY_PLACE_RECORDED;
case POSITION -> EventType.POSITION_RECORDED; case POSITION -> EventType.POSITION_RECORDED;
@ -528,11 +540,18 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE
} }
yield EventType.UNKNOWN_EVENT; yield EventType.UNKNOWN_EVENT;
} }
case BORDER_CROSSING -> EventType.BORDER_OUTBOUND;
case LOAD_UNLOAD -> EventType.LOAD_UNLOAD;
case SPEEDING -> EventType.SPEEDING;
default -> parseEnum(EventType.class, eventType, EventType.UNKNOWN_EVENT); default -> parseEnum(EventType.class, eventType, EventType.UNKNOWN_EVENT);
}; };
} }
private EventLifecycle supportEventLifecycle(EventDomain eventDomain, String eventType) { private EventLifecycle supportEventLifecycle(EventDomain eventDomain, String eventType, String lifecycle) {
EventLifecycle explicit = parseEnum(EventLifecycle.class, lifecycle, null);
if (explicit != null) {
return explicit;
}
if (eventDomain == EventDomain.PLACE) { if (eventDomain == EventDomain.PLACE) {
String normalized = normalizeToken(eventType); String normalized = normalizeToken(eventType);
if (normalized != null && normalized.startsWith("BEGIN")) { if (normalized != null && normalized.startsWith("BEGIN")) {
@ -553,8 +572,11 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE
private EventDetailsDto supportDetails(EventDomain eventDomain, ExtractedSupportEvent supportEvent) { private EventDetailsDto supportDetails(EventDomain eventDomain, ExtractedSupportEvent supportEvent) {
return switch (eventDomain) { return switch (eventDomain) {
case PLACE -> detailsFactory.place(supportEvent.country(), supportEvent.region()); case PLACE -> detailsFactory.place(supportEvent.country(), supportEvent.region());
case POSITION -> detailsFactory.position(supportEvent.eventType()); case POSITION -> detailsFactory.position("GNSS_ACCUMULATED_DRIVING");
case BORDER_CROSSING -> detailsFactory.borderCrossing(supportEvent.countryFrom(), supportEvent.countryTo());
case LOAD_UNLOAD -> detailsFactory.loadUnload(supportEvent.operation());
case SPECIFIC_CONDITION -> detailsFactory.specificCondition(); case SPECIFIC_CONDITION -> detailsFactory.specificCondition();
case SPEEDING -> detailsFactory.speeding(supportEvent.avgSpeedKmh(), supportEvent.maxSpeedKmh(), null);
default -> new EventDetailsDto("TACHOGRAPH_SUPPORT", detailsFactory.payloadFromMap(Map.of())); default -> new EventDetailsDto("TACHOGRAPH_SUPPORT", detailsFactory.payloadFromMap(Map.of()));
}; };
} }

View File

@ -369,6 +369,9 @@ public class VehicleUnitXmlExtractionService {
extractVuPlaceSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings); extractVuPlaceSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuGnssSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings); extractVuGnssSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuSpecificConditionSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings); extractVuSpecificConditionSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuBorderCrossingSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuLoadUnloadSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuSpeedingSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
} }
private void extractVuPlaceSupportEvents( private void extractVuPlaceSupportEvents(
@ -415,16 +418,22 @@ public class VehicleUnitXmlExtractionService {
occurredAt, occurredAt,
"PLACE", "PLACE",
mapPlaceEntryType(entryType), mapPlaceEntryType(entryType),
null,
assignment.slot(), assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(), vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(), vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
text(record, "placeRecord/dailyWorkPeriodCountry"), text(record, "placeRecord/dailyWorkPeriodCountry"),
text(record, "placeRecord/dailyWorkPeriodRegion"), text(record, "placeRecord/dailyWorkPeriodRegion"),
null,
null,
null,
latitude, latitude,
longitude, longitude,
authenticationStatus, authenticationStatus,
longValue(text(record, "placeRecord/vehicleOdometerValue")), longValue(text(record, "placeRecord/vehicleOdometerValue")),
normalizeToken(entryType), normalizeToken(entryType),
null,
null,
path path
), ),
warnings warnings
@ -480,17 +489,23 @@ public class VehicleUnitXmlExtractionService {
"VUGNSS-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(), "VUGNSS-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt, occurredAt,
"POSITION", "POSITION",
"GNSS_ACCUMULATED_DRIVING", "POSITION_RECORDED",
"SNAPSHOT",
assignment.slot(), assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(), vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(), vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null, null,
null, null,
null,
null,
null,
latitude, latitude,
longitude, longitude,
authenticationStatus, authenticationStatus,
longValue(text(record, "vehicleOdometerValue")), longValue(text(record, "vehicleOdometerValue")),
null, null,
null,
null,
path path
), ),
warnings warnings
@ -531,6 +546,7 @@ public class VehicleUnitXmlExtractionService {
} }
String conditionCode = normalizeToken(text(record, "specificConditionType")); String conditionCode = normalizeToken(text(record, "specificConditionType"));
String[] specificCondition = mapSpecificCondition(conditionCode);
for (DriverAssignment assignment : assignments) { for (DriverAssignment assignment : assignments) {
addSupportEvent( addSupportEvent(
driversByKey, driversByKey,
@ -539,7 +555,8 @@ public class VehicleUnitXmlExtractionService {
"VUSC-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(), "VUSC-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt, occurredAt,
"SPECIFIC_CONDITION", "SPECIFIC_CONDITION",
"SPECIFIC_CONDITION", specificCondition[0],
specificCondition[1],
assignment.slot(), assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(), vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(), vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
@ -549,7 +566,12 @@ public class VehicleUnitXmlExtractionService {
null, null,
null, null,
null, null,
null,
null,
null,
conditionCode, conditionCode,
null,
null,
path path
), ),
warnings warnings
@ -558,6 +580,247 @@ public class VehicleUnitXmlExtractionService {
} }
} }
private void extractVuBorderCrossingSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/VehicleUnit/Activities/vuBorderCrossingData/vuBorderCrossingRecord");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/Activities/vuBorderCrossingData/vuBorderCrossingRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(text(record, "gnssPlaceAuthRecord/timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("VU_BORDER_CROSSING_MISSING_TIME", "Vehicle-unit border-crossing record is missing gnssPlaceAuthRecord/timeStamp.", path));
continue;
}
List<DriverAssignment> assignments = new ArrayList<>();
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberDriverSlot"),
"DRIVER",
vuCardIwIntervals
));
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberCodriverSlot"),
"CO_DRIVER",
vuCardIwIntervals
));
assignments = distinctAssignments(assignments);
if (assignments.isEmpty()) {
warnings.add(new ExtractionWarning("VU_BORDER_CROSSING_UNASSIGNED", "Vehicle-unit border-crossing record could not be assigned to an active driver session.", path));
continue;
}
BigDecimal latitude = geoCoordinate(record, "gnssPlaceAuthRecord/geoCoordinates/latitude", true);
BigDecimal longitude = geoCoordinate(record, "gnssPlaceAuthRecord/geoCoordinates/longitude", false);
String authenticationStatus = normalizeToken(text(record, "gnssPlaceAuthRecord/authenticationStatus"));
for (DriverAssignment assignment : assignments) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VUBORDER-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt,
"BORDER_CROSSING",
"BORDER_OUTBOUND",
"OUTBOUND",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
text(record, "countryLeft"),
text(record, "countryEntered"),
null,
latitude,
longitude,
authenticationStatus,
longValue(text(record, "vehicleOdometerValue")),
null,
null,
null,
path
),
warnings
);
}
}
}
private void extractVuLoadUnloadSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/VehicleUnit/Activities/vuLoadUnloadData/vuLoadUnloadRecord");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/Activities/vuLoadUnloadData/vuLoadUnloadRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(text(record, "timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("VU_LOAD_UNLOAD_MISSING_TIME", "Vehicle-unit load/unload record is missing timeStamp.", path));
continue;
}
List<DriverAssignment> assignments = new ArrayList<>();
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberDriverSlot"),
"DRIVER",
vuCardIwIntervals
));
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberCodriverSlot"),
"CO_DRIVER",
vuCardIwIntervals
));
assignments = distinctAssignments(assignments);
if (assignments.isEmpty()) {
warnings.add(new ExtractionWarning("VU_LOAD_UNLOAD_UNASSIGNED", "Vehicle-unit load/unload record could not be assigned to an active driver session.", path));
continue;
}
BigDecimal latitude = geoCoordinate(record, "gnssPlaceAuthRecord/geoCoordinates/latitude", true);
BigDecimal longitude = geoCoordinate(record, "gnssPlaceAuthRecord/geoCoordinates/longitude", false);
String authenticationStatus = normalizeToken(text(record, "gnssPlaceAuthRecord/authenticationStatus"));
String operation = mapOperation(text(record, "operationType"));
for (DriverAssignment assignment : assignments) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VULOAD-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt,
"LOAD_UNLOAD",
operation,
"SNAPSHOT",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
null,
null,
operation,
latitude,
longitude,
authenticationStatus,
longValue(text(record, "vehicleOdometerValue")),
normalizeToken(text(record, "operationType")),
null,
null,
path
),
warnings
);
}
}
}
private void extractVuSpeedingSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/VehicleUnit/EventsFaults/vuOverSpeedingEventData/vuOverSpeedingEventRecords");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/EventsFaults/vuOverSpeedingEventData/vuOverSpeedingEventRecords[" + (i + 1) + "]";
OffsetDateTime beginAt = offsetDateTime(text(record, "eventBeginTime"));
OffsetDateTime endAt = offsetDateTime(text(record, "eventEndTime"));
if (beginAt == null && endAt == null) {
warnings.add(new ExtractionWarning("VU_SPEEDING_MISSING_TIME", "Vehicle-unit speeding record is missing both eventBeginTime and eventEndTime.", path));
continue;
}
List<DriverAssignment> assignments = distinctAssignments(resolveDriverAssignments(
beginAt != null ? beginAt : endAt,
driverKeyFromCardNode(record, "cardNumberDriverSlotBegin"),
"DRIVER",
vuCardIwIntervals
));
if (assignments.isEmpty()) {
warnings.add(new ExtractionWarning("VU_SPEEDING_UNASSIGNED", "Vehicle-unit speeding record could not be assigned to an active driver session.", path));
continue;
}
BigDecimal avgSpeedKmh = decimalValue(text(record, "averageSpeedValue"));
BigDecimal maxSpeedKmh = decimalValue(text(record, "maxSpeedValue"));
for (DriverAssignment assignment : assignments) {
if (beginAt != null) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VUSPEED-" + (i + 1) + "-" + assignment.driverKey() + "-BEGIN",
beginAt,
"SPEEDING",
"SPEEDING",
"BEGIN",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
null,
null,
null,
null,
null,
null,
null,
normalizeToken(text(record, "eventType")),
avgSpeedKmh,
maxSpeedKmh,
path
),
warnings
);
}
if (endAt != null) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VUSPEED-" + (i + 1) + "-" + assignment.driverKey() + "-END",
endAt,
"SPEEDING",
"SPEEDING",
"END",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
null,
null,
null,
null,
null,
null,
null,
normalizeToken(text(record, "eventType")),
avgSpeedKmh,
maxSpeedKmh,
path
),
warnings
);
}
}
}
}
private boolean isPartiallyCovered(ExtractedCardActivityInterval interval, List<ActivitySegment> segments) { private boolean isPartiallyCovered(ExtractedCardActivityInterval interval, List<ActivitySegment> segments) {
if (segments.isEmpty()) { if (segments.isEmpty()) {
return true; return true;
@ -696,6 +959,13 @@ public class VehicleUnitXmlExtractionService {
return Long.parseLong(value.trim()); return Long.parseLong(value.trim());
} }
private BigDecimal decimalValue(String value) {
if (value == null || value.isBlank()) {
return null;
}
return new BigDecimal(value.trim());
}
private BigDecimal geoCoordinate(Object node, String expression, boolean latitude) { private BigDecimal geoCoordinate(Object node, String expression, boolean latitude) {
String value = text(node, expression); String value = text(node, expression);
if (value == null) { if (value == null) {
@ -759,6 +1029,32 @@ public class VehicleUnitXmlExtractionService {
}; };
} }
private String[] mapSpecificCondition(String conditionCode) {
String normalized = normalizeToken(conditionCode);
if (normalized == null) {
return new String[]{"UNKNOWN_EVENT", "SNAPSHOT"};
}
return switch (normalized) {
case "1" -> new String[]{"OUT", "BEGIN"};
case "2" -> new String[]{"OUT", "END"};
case "3" -> new String[]{"FERRY_TRAIN", "BEGIN"};
case "4" -> new String[]{"FERRY_TRAIN", "END"};
default -> new String[]{"UNKNOWN_EVENT", "SNAPSHOT"};
};
}
private String mapOperation(String operationType) {
String normalized = normalizeToken(operationType);
if (normalized == null) {
return "LOAD_UNLOAD";
}
return switch (normalized) {
case "1", "LOAD" -> "LOAD";
case "2", "UNLOAD" -> "UNLOAD";
default -> "LOAD_UNLOAD";
};
}
private String joinCardNumber(Element node, String basePath) { private String joinCardNumber(Element node, String basePath) {
String driverIdentification = text(node, basePath + "/driverIdentification"); String driverIdentification = text(node, basePath + "/driverIdentification");
if (driverIdentification == null) { if (driverIdentification == null) {

View File

@ -49,6 +49,13 @@ class DriverCardXmlExtractionServiceTest {
assertThat(driver.cardActivityIntervals().get(0).registrationKey()).isEqualTo("12:W-12345A"); 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(1).to()).isEqualTo(driver.cardVehicleUsageIntervals().get(0).to().plusSeconds(1));
assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B"); assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B");
assertThat(driver.supportEvents()).hasSize(5);
assertThat(driver.supportEvents()).extracting("eventDomain")
.containsExactly("PLACE", "POSITION", "SPECIFIC_CONDITION", "BORDER_CROSSING", "LOAD_UNLOAD");
assertThat(driver.supportEvents().get(2).eventType()).isEqualTo("FERRY_TRAIN");
assertThat(driver.supportEvents().get(2).eventLifecycle()).isEqualTo("BEGIN");
assertThat(driver.supportEvents().get(3).countryFrom()).isEqualTo("12");
assertThat(driver.supportEvents().get(4).operation()).isEqualTo("UNLOAD");
} }
@Test @Test

View File

@ -109,6 +109,92 @@ final class DriverCardXmlSamples {
</cardDriverActivity> </cardDriverActivity>
<signature></signature> <signature></signature>
</DriverActivityData> </DriverActivityData>
<Places>
<cardPlaceDailyWorkPeriod>
<placeRecords>
<entryTime>2026-04-01T08:00:00Z</entryTime>
<entryTypeDailyWorkPeriod>0</entryTypeDailyWorkPeriod>
<dailyWorkPeriodCountry>12</dailyWorkPeriodCountry>
<dailyWorkPeriodRegion>9</dailyWorkPeriodRegion>
<vehicleOdometerValue>1000</vehicleOdometerValue>
<entryGnssPlaceRecord>
<timeStamp>2026-04-01T08:00:00Z</timeStamp>
<gnssAccuracy>5</gnssAccuracy>
<geoCoordinates>
<latitude>48.2082</latitude>
<longitude>16.3738</longitude>
</geoCoordinates>
<authenticationStatus>1</authenticationStatus>
</entryGnssPlaceRecord>
</placeRecords>
</cardPlaceDailyWorkPeriod>
<signature></signature>
</Places>
<SpecificConditions>
<specificConditionData>
<specificConditionRecord>
<entryTime>2026-04-01T10:30:00Z</entryTime>
<specificConditionType>3</specificConditionType>
</specificConditionRecord>
</specificConditionData>
<signature></signature>
</SpecificConditions>
<GnssPlaces>
<gnssAccumulatedDriving>
<gnssAccumulatedDrivingRecord>
<timeStamp>2026-04-01T09:30:00Z</timeStamp>
<gnssPlaceRecord>
<timeStamp>2026-04-01T09:30:00Z</timeStamp>
<gnssAccuracy>4</gnssAccuracy>
<geoCoordinates>
<latitude>48.2000</latitude>
<longitude>16.3600</longitude>
</geoCoordinates>
<authenticationStatus>1</authenticationStatus>
</gnssPlaceRecord>
<vehicleOdometerValue>1050</vehicleOdometerValue>
</gnssAccumulatedDrivingRecord>
</gnssAccumulatedDriving>
<signature></signature>
</GnssPlaces>
<BorderCrossings>
<cardBorderCrossings>
<cardBorderCrossingRecord>
<countryLeft>12</countryLeft>
<countryEntered>56</countryEntered>
<gnssPlaceAuthRecord>
<timeStamp>2026-04-01T12:15:00Z</timeStamp>
<gnssAccuracy>4</gnssAccuracy>
<geoCoordinates>
<latitude>48.3000</latitude>
<longitude>16.5000</longitude>
</geoCoordinates>
<authenticationStatus>0</authenticationStatus>
</gnssPlaceAuthRecord>
<vehicleOdometerValue>1120</vehicleOdometerValue>
</cardBorderCrossingRecord>
</cardBorderCrossings>
<signature></signature>
</BorderCrossings>
<LoadUnloadOperations>
<cardLoadUnloadOperations>
<cardLoadUnloadRecord>
<timeStamp>2026-04-01T12:45:00Z</timeStamp>
<operationType>2</operationType>
<gnssPlaceAuthRecord>
<timeStamp>2026-04-01T12:45:00Z</timeStamp>
<gnssAccuracy>3</gnssAccuracy>
<geoCoordinates>
<latitude>48.3100</latitude>
<longitude>16.5100</longitude>
</geoCoordinates>
<authenticationStatus>1</authenticationStatus>
</gnssPlaceAuthRecord>
<vehicleOdometerValue>1130</vehicleOdometerValue>
</cardLoadUnloadRecord>
</cardLoadUnloadOperations>
<signature></signature>
</LoadUnloadOperations>
</DriverCard> </DriverCard>
"""; """;
} }

View File

@ -77,6 +77,7 @@ class DriverTimelineBuilderTest {
OffsetDateTime.parse("2026-05-01T08:30:00Z"), OffsetDateTime.parse("2026-05-01T08:30:00Z"),
"PLACE", "PLACE",
"BEGIN_DAILY_WORK_PERIOD", "BEGIN_DAILY_WORK_PERIOD",
null,
"DRIVER", "DRIVER",
"12:REG-1", "12:REG-1",
"VIN-1", "VIN-1",
@ -87,6 +88,11 @@ class DriverTimelineBuilderTest {
null, null,
null, null,
null, null,
null,
null,
null,
null,
null,
"d" "d"
)), )),
List.of(new ExtractionWarning("W1", "warning", "/x")) List.of(new ExtractionWarning("W1", "warning", "/x"))

View File

@ -72,17 +72,23 @@ class EventBackedDriverTimelineBuilderTest {
"SUP-1", "SUP-1",
OffsetDateTime.parse("2026-05-01T08:45:00Z"), OffsetDateTime.parse("2026-05-01T08:45:00Z"),
"POSITION", "POSITION",
"GNSS_ACCUMULATED_DRIVING", "POSITION_RECORDED",
"SNAPSHOT",
"DRIVER", "DRIVER",
"12:REG-1", "12:REG-1",
"VIN-1", "VIN-1",
null, null,
null, null,
null,
null,
null,
BigDecimal.valueOf(48.2082), BigDecimal.valueOf(48.2082),
BigDecimal.valueOf(16.3738), BigDecimal.valueOf(16.3738),
"AUTHENTIC", "AUTHENTIC",
150L, 150L,
null, null,
null,
null,
"raw-path" "raw-path"
)), )),
List.of() List.of()

View File

@ -110,17 +110,23 @@ class IntervalBackedDriverTimelineEventBuilderTest {
"VUGNSS-1", "VUGNSS-1",
OffsetDateTime.parse("2026-05-01T08:45:00Z"), OffsetDateTime.parse("2026-05-01T08:45:00Z"),
"POSITION", "POSITION",
"GNSS_ACCUMULATED_DRIVING", "POSITION_RECORDED",
"SNAPSHOT",
"DRIVER", "DRIVER",
"12:REG-1", "12:REG-1",
"VIN-1", "VIN-1",
null, null,
null, null,
null,
null,
null,
BigDecimal.valueOf(48.2082), BigDecimal.valueOf(48.2082),
BigDecimal.valueOf(16.3738), BigDecimal.valueOf(16.3738),
"AUTHENTIC", "AUTHENTIC",
150L, 150L,
null, null,
null,
null,
"raw-path" "raw-path"
)), )),
List.of() List.of()

View File

@ -65,10 +65,17 @@ class VehicleUnitXmlExtractionServiceTest {
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to()).isNull(); assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to()).isNull();
assertThat(secondDriver.cardActivityIntervals()).hasSize(1); assertThat(secondDriver.cardActivityIntervals()).hasSize(1);
assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z"); assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z");
assertThat(secondDriver.supportEvents()).hasSize(2); assertThat(secondDriver.supportEvents()).hasSize(6);
assertThat(secondDriver.supportEvents()).extracting("eventDomain").containsExactly("POSITION", "SPECIFIC_CONDITION"); assertThat(secondDriver.supportEvents()).extracting("eventDomain")
.containsExactly("POSITION", "SPECIFIC_CONDITION", "BORDER_CROSSING", "LOAD_UNLOAD", "SPEEDING", "SPEEDING");
assertThat(secondDriver.supportEvents().get(0).latitude()).isNotNull(); assertThat(secondDriver.supportEvents().get(0).latitude()).isNotNull();
assertThat(secondDriver.supportEvents().get(1).code()).isEqualTo("1"); assertThat(secondDriver.supportEvents().get(1).eventType()).isEqualTo("OUT");
assertThat(secondDriver.supportEvents().get(1).eventLifecycle()).isEqualTo("BEGIN");
assertThat(secondDriver.supportEvents().get(2).countryFrom()).isEqualTo("12");
assertThat(secondDriver.supportEvents().get(2).countryTo()).isEqualTo("56");
assertThat(secondDriver.supportEvents().get(3).operation()).isEqualTo("LOAD");
assertThat(secondDriver.supportEvents().get(4).eventLifecycle()).isEqualTo("BEGIN");
assertThat(secondDriver.supportEvents().get(5).eventLifecycle()).isEqualTo("END");
assertThat(session.warnings()).isEmpty(); assertThat(session.warnings()).isEmpty();
} }

View File

@ -169,7 +169,80 @@ final class VehicleUnitXmlSamples {
<specificConditionType>1</specificConditionType> <specificConditionType>1</specificConditionType>
</specificConditionRecords> </specificConditionRecords>
</vuSpecificConditionData> </vuSpecificConditionData>
<vuBorderCrossingData>
<vuBorderCrossingRecord>
<cardNumberDriverSlot>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>999999999999</driverIdentification>
<cardReplacementIndex>1</cardReplacementIndex>
<cardRenewalIndex>1</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</cardNumberDriverSlot>
<countryLeft>12</countryLeft>
<countryEntered>56</countryEntered>
<gnssPlaceAuthRecord>
<timeStamp>2026-04-02T08:30:00Z</timeStamp>
<geoCoordinates>
<latitude>48020</latitude>
<longitude>16380</longitude>
</geoCoordinates>
<authenticationStatus>AUTHENTICATED</authenticationStatus>
</gnssPlaceAuthRecord>
<vehicleOdometerValue>1220</vehicleOdometerValue>
</vuBorderCrossingRecord>
</vuBorderCrossingData>
<vuLoadUnloadData>
<vuLoadUnloadRecord>
<timeStamp>2026-04-02T08:45:00Z</timeStamp>
<operationType>1</operationType>
<cardNumberDriverSlot>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>999999999999</driverIdentification>
<cardReplacementIndex>1</cardReplacementIndex>
<cardRenewalIndex>1</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</cardNumberDriverSlot>
<gnssPlaceAuthRecord>
<timeStamp>2026-04-02T08:45:00Z</timeStamp>
<geoCoordinates>
<latitude>48025</latitude>
<longitude>16385</longitude>
</geoCoordinates>
<authenticationStatus>AUTHENTICATED</authenticationStatus>
</gnssPlaceAuthRecord>
<vehicleOdometerValue>1225</vehicleOdometerValue>
</vuLoadUnloadRecord>
</vuLoadUnloadData>
</Activities> </Activities>
<EventsFaults>
<vuOverSpeedingEventData>
<vuOverSpeedingEventRecords>
<eventType>1</eventType>
<eventRecordPurpose>1</eventRecordPurpose>
<eventBeginTime>2026-04-02T08:10:00Z</eventBeginTime>
<eventEndTime>2026-04-02T08:12:00Z</eventEndTime>
<maxSpeedValue>96</maxSpeedValue>
<averageSpeedValue>91</averageSpeedValue>
<cardNumberDriverSlotBegin>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>999999999999</driverIdentification>
<cardReplacementIndex>1</cardReplacementIndex>
<cardRenewalIndex>1</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</cardNumberDriverSlotBegin>
<similarEventsNumber>1</similarEventsNumber>
</vuOverSpeedingEventRecords>
</vuOverSpeedingEventData>
</EventsFaults>
</VehicleUnit> </VehicleUnit>
"""; """;
} }