diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/ExtractedSupportEvent.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/ExtractedSupportEvent.java index 07def58..76fd8fc 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/model/ExtractedSupportEvent.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/ExtractedSupportEvent.java @@ -8,16 +8,22 @@ public record ExtractedSupportEvent( OffsetDateTime occurredAt, String eventDomain, String eventType, + String eventLifecycle, String slot, String registrationKey, String vehicleKey, String country, String region, + String countryFrom, + String countryTo, + String operation, BigDecimal latitude, BigDecimal longitude, String authenticationStatus, Long odometerKm, String code, + BigDecimal avgSpeedKmh, + BigDecimal maxSpeedKmh, String rawRecordPath ) { } diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java index 750ba8e..8783a8a 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java @@ -5,12 +5,14 @@ import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInter import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; import at.procon.eventhub.tachographfilesession.model.ExtractedDriver; 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.ExtractedVehicleRegistration; import at.procon.eventhub.tachographfilesession.model.ExtractionStats; import at.procon.eventhub.tachographfilesession.model.ExtractionWarning; import at.procon.eventhub.tachographfilesession.model.TachographFileSession; import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; +import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; import java.time.LocalTime; @@ -71,6 +73,7 @@ public class DriverCardXmlExtractionService { extractVehicleUsageIntervals(document, registrationsByKey, vehiclesByKey, warnings); List activityIntervals = assignVehicleCoverage(extractActivityIntervals(document, warnings), vehicleUsageIntervals); + List supportEvents = extractSupportEvents(document, vehicleUsageIntervals, warnings); DriverExtractionSession driverSession = new DriverExtractionSession( driverKey, @@ -80,7 +83,7 @@ public class DriverCardXmlExtractionService { List.copyOf(vehiclesByKey.values()), List.copyOf(vehicleUsageIntervals), List.copyOf(activityIntervals), - List.of(), + List.copyOf(supportEvents), List.copyOf(warnings) ); Map driversByKey = Map.of(driverKey, driverSession); @@ -302,6 +305,284 @@ public class DriverCardXmlExtractionService { return result; } + private List extractSupportEvents( + Document document, + List vehicleUsageIntervals, + List warnings + ) { + VehicleUsageLookup vehicleUsageLookup = new VehicleUsageLookup(vehicleUsageIntervals); + List 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 supportEvents, + List warnings + ) { + List sections = children(root, "Places"); + for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) { + Element cardPlaceDailyWorkPeriod = child(sections.get(sectionIndex), "cardPlaceDailyWorkPeriod"); + List 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 supportEvents, + List warnings + ) { + List sections = children(root, "GnssPlaces"); + for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) { + Element gnssAccumulatedDriving = child(sections.get(sectionIndex), "gnssAccumulatedDriving"); + List 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 supportEvents, + List warnings + ) { + List sections = children(root, "SpecificConditions"); + for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) { + Element specificConditionData = child(sections.get(sectionIndex), "specificConditionData"); + List 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 supportEvents, + List warnings + ) { + List sections = children(root, "BorderCrossings"); + for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) { + Element cardBorderCrossings = child(sections.get(sectionIndex), "cardBorderCrossings"); + List 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 supportEvents, + List warnings + ) { + List sections = children(root, "LoadUnloadOperations"); + for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) { + Element cardLoadUnloadOperations = child(sections.get(sectionIndex), "cardLoadUnloadOperations"); + List 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 splitByVehicleCoverage( ExtractedCardActivityInterval interval, List overlappingUsages @@ -366,6 +647,26 @@ public class DriverCardXmlExtractionService { 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) { if (parent == null) { return null; @@ -456,6 +757,50 @@ public class DriverCardXmlExtractionService { 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( OffsetDateTime from, String activityType, @@ -484,4 +829,42 @@ public class DriverCardXmlExtractionService { } return builder.toString(); } + + private final class VehicleUsageLookup { + private final List intervals; + + private VehicleUsageLookup(List 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; + } + } } diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/EventBackedDriverTimelineBuilder.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/EventBackedDriverTimelineBuilder.java index 35abf56..111e64f 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/EventBackedDriverTimelineBuilder.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/EventBackedDriverTimelineBuilder.java @@ -152,16 +152,22 @@ public class EventBackedDriverTimelineBuilder { event.occurredAt(), event.eventDomain().name(), text(raw, "supportEventType") == null ? event.eventType().name() : text(raw, "supportEventType"), + event.lifecycle().name(), text(raw, "slot"), text(raw, "registrationKey"), text(raw, "vehicleKey"), text(raw, "country"), text(raw, "region"), + text(raw, "countryFrom"), + text(raw, "countryTo"), + text(raw, "operation"), latitude, longitude, text(raw, "authenticationStatus"), longValue(raw, "odometerKm"), text(raw, "code"), + decimal(raw, "avgSpeedKmh"), + decimal(raw, "maxSpeedKmh"), text(raw, "rawRecordPath") )); } @@ -289,6 +295,21 @@ public class EventBackedDriverTimelineBuilder { 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 stringList(JsonNode node, String field) { if (node == null || field == null) { return List.of(); diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/IntervalBackedDriverTimelineEventBuilder.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/IntervalBackedDriverTimelineEventBuilder.java index a8c3b01..7ae23c6 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/IntervalBackedDriverTimelineEventBuilder.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/IntervalBackedDriverTimelineEventBuilder.java @@ -294,7 +294,7 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE for (ExtractedSupportEvent supportEvent : supportEvents) { EventDomain eventDomain = supportEventDomain(supportEvent.eventDomain()); 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()); VehicleRefDto vehicleRef = vehicleRef( supportEvent.registrationKey(), @@ -312,9 +312,14 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE raw.put("vehicleKey", supportEvent.vehicleKey()); raw.put("country", supportEvent.country()); 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("odometerKm", supportEvent.odometerKm()); raw.put("code", supportEvent.code()); + raw.put("avgSpeedKmh", supportEvent.avgSpeedKmh()); + raw.put("maxSpeedKmh", supportEvent.maxSpeedKmh()); raw.put("rawRecordPath", supportEvent.rawRecordPath()); events.add(event( @@ -509,12 +514,19 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE return switch (normalizeToken(value)) { case "PLACE" -> EventDomain.PLACE; case "POSITION" -> EventDomain.POSITION; + case "BORDER_CROSSING" -> EventDomain.BORDER_CROSSING; + case "LOAD_UNLOAD" -> EventDomain.LOAD_UNLOAD; case "SPECIFIC_CONDITION" -> EventDomain.SPECIFIC_CONDITION; + case "SPEEDING" -> EventDomain.SPEEDING; default -> EventDomain.TELEMATICS_DATA; }; } private EventType supportEventType(EventDomain eventDomain, String eventType, String code) { + EventType explicit = parseEnum(EventType.class, eventType, null); + if (explicit != null) { + return explicit; + } return switch (eventDomain) { case PLACE -> EventType.WORKING_DAY_PLACE_RECORDED; case POSITION -> EventType.POSITION_RECORDED; @@ -528,11 +540,18 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE } 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); }; } - 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) { String normalized = normalizeToken(eventType); if (normalized != null && normalized.startsWith("BEGIN")) { @@ -553,8 +572,11 @@ public class IntervalBackedDriverTimelineEventBuilder implements DriverTimelineE private EventDetailsDto supportDetails(EventDomain eventDomain, ExtractedSupportEvent supportEvent) { return switch (eventDomain) { 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 SPEEDING -> detailsFactory.speeding(supportEvent.avgSpeedKmh(), supportEvent.maxSpeedKmh(), null); default -> new EventDetailsDto("TACHOGRAPH_SUPPORT", detailsFactory.payloadFromMap(Map.of())); }; } 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 f703272..a08ac29 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java @@ -369,6 +369,9 @@ public class VehicleUnitXmlExtractionService { extractVuPlaceSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings); extractVuGnssSupportEvents(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( @@ -415,16 +418,22 @@ public class VehicleUnitXmlExtractionService { occurredAt, "PLACE", mapPlaceEntryType(entryType), + null, assignment.slot(), vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(), vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(), text(record, "placeRecord/dailyWorkPeriodCountry"), text(record, "placeRecord/dailyWorkPeriodRegion"), + null, + null, + null, latitude, longitude, authenticationStatus, longValue(text(record, "placeRecord/vehicleOdometerValue")), normalizeToken(entryType), + null, + null, path ), warnings @@ -480,17 +489,23 @@ public class VehicleUnitXmlExtractionService { "VUGNSS-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(), occurredAt, "POSITION", - "GNSS_ACCUMULATED_DRIVING", + "POSITION_RECORDED", + "SNAPSHOT", assignment.slot(), vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(), vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(), null, null, + null, + null, + null, latitude, longitude, authenticationStatus, longValue(text(record, "vehicleOdometerValue")), null, + null, + null, path ), warnings @@ -531,6 +546,7 @@ public class VehicleUnitXmlExtractionService { } String conditionCode = normalizeToken(text(record, "specificConditionType")); + String[] specificCondition = mapSpecificCondition(conditionCode); for (DriverAssignment assignment : assignments) { addSupportEvent( driversByKey, @@ -539,7 +555,8 @@ public class VehicleUnitXmlExtractionService { "VUSC-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(), occurredAt, "SPECIFIC_CONDITION", - "SPECIFIC_CONDITION", + specificCondition[0], + specificCondition[1], assignment.slot(), vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(), vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(), @@ -549,7 +566,12 @@ public class VehicleUnitXmlExtractionService { null, null, null, + null, + null, + null, conditionCode, + null, + null, path ), warnings @@ -558,6 +580,247 @@ public class VehicleUnitXmlExtractionService { } } + private void extractVuBorderCrossingSupportEvents( + Document document, + VehicleContext vehicleContext, + List vuCardIwIntervals, + Map driversByKey, + List 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 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 vuCardIwIntervals, + Map driversByKey, + List 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 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 vuCardIwIntervals, + Map driversByKey, + List 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 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 segments) { if (segments.isEmpty()) { return true; @@ -696,6 +959,13 @@ public class VehicleUnitXmlExtractionService { 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) { String value = text(node, expression); 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) { String driverIdentification = text(node, basePath + "/driverIdentification"); if (driverIdentification == null) { diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java index 3c30e4c..83617aa 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java @@ -49,6 +49,13 @@ class DriverCardXmlExtractionServiceTest { 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.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 diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java index 34d653f..3aea97f 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlSamples.java @@ -109,6 +109,92 @@ final class DriverCardXmlSamples { + + + + 2026-04-01T08:00:00Z + 0 + 12 + 9 + 1000 + + 2026-04-01T08:00:00Z + 5 + + 48.2082 + 16.3738 + + 1 + + + + + + + + + 2026-04-01T10:30:00Z + 3 + + + + + + + + 2026-04-01T09:30:00Z + + 2026-04-01T09:30:00Z + 4 + + 48.2000 + 16.3600 + + 1 + + 1050 + + + + + + + + 12 + 56 + + 2026-04-01T12:15:00Z + 4 + + 48.3000 + 16.5000 + + 0 + + 1120 + + + + + + + + 2026-04-01T12:45:00Z + 2 + + 2026-04-01T12:45:00Z + 3 + + 48.3100 + 16.5100 + + 1 + + 1130 + + + + """; } diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java index 3c7e8bf..ef2e0d1 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java @@ -77,6 +77,7 @@ class DriverTimelineBuilderTest { OffsetDateTime.parse("2026-05-01T08:30:00Z"), "PLACE", "BEGIN_DAILY_WORK_PERIOD", + null, "DRIVER", "12:REG-1", "VIN-1", @@ -87,6 +88,11 @@ class DriverTimelineBuilderTest { null, null, null, + null, + null, + null, + null, + null, "d" )), List.of(new ExtractionWarning("W1", "warning", "/x")) diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/EventBackedDriverTimelineBuilderTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/EventBackedDriverTimelineBuilderTest.java index 74968ce..55e3421 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/EventBackedDriverTimelineBuilderTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/EventBackedDriverTimelineBuilderTest.java @@ -72,17 +72,23 @@ class EventBackedDriverTimelineBuilderTest { "SUP-1", OffsetDateTime.parse("2026-05-01T08:45:00Z"), "POSITION", - "GNSS_ACCUMULATED_DRIVING", + "POSITION_RECORDED", + "SNAPSHOT", "DRIVER", "12:REG-1", "VIN-1", null, null, + null, + null, + null, BigDecimal.valueOf(48.2082), BigDecimal.valueOf(16.3738), "AUTHENTIC", 150L, null, + null, + null, "raw-path" )), List.of() diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/IntervalBackedDriverTimelineEventBuilderTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/IntervalBackedDriverTimelineEventBuilderTest.java index 29186d6..f4953dc 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/IntervalBackedDriverTimelineEventBuilderTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/IntervalBackedDriverTimelineEventBuilderTest.java @@ -110,17 +110,23 @@ class IntervalBackedDriverTimelineEventBuilderTest { "VUGNSS-1", OffsetDateTime.parse("2026-05-01T08:45:00Z"), "POSITION", - "GNSS_ACCUMULATED_DRIVING", + "POSITION_RECORDED", + "SNAPSHOT", "DRIVER", "12:REG-1", "VIN-1", null, null, + null, + null, + null, BigDecimal.valueOf(48.2082), BigDecimal.valueOf(16.3738), "AUTHENTIC", 150L, null, + null, + null, "raw-path" )), List.of() 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 e2e05ff..ab2909d 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java @@ -65,10 +65,17 @@ class VehicleUnitXmlExtractionServiceTest { 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.supportEvents()).hasSize(2); - assertThat(secondDriver.supportEvents()).extracting("eventDomain").containsExactly("POSITION", "SPECIFIC_CONDITION"); + assertThat(secondDriver.supportEvents()).hasSize(6); + 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(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(); } 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 3ee068d..42c203f 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java @@ -169,7 +169,80 @@ final class VehicleUnitXmlSamples { 1 + + + + DRIVER_CARD + 12 + + 999999999999 + 1 + 1 + + 2 + + 12 + 56 + + 2026-04-02T08:30:00Z + + 48020 + 16380 + + AUTHENTICATED + + 1220 + + + + + 2026-04-02T08:45:00Z + 1 + + DRIVER_CARD + 12 + + 999999999999 + 1 + 1 + + 2 + + + 2026-04-02T08:45:00Z + + 48025 + 16385 + + AUTHENTICATED + + 1225 + + + + + + 1 + 1 + 2026-04-02T08:10:00Z + 2026-04-02T08:12:00Z + 96 + 91 + + DRIVER_CARD + 12 + + 999999999999 + 1 + 1 + + 2 + + 1 + + + """; }