From a20d4c241e0ca620a0cb0178a7c9a73fd1d727f7 Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Tue, 12 May 2026 17:05:45 +0200 Subject: [PATCH] Add tachograph file session operating-period processing --- ...eventhub-esper-poc.postman_collection.json | 32 + .../eventhub/config/EventHubProperties.java | 44 ++ .../api/TachographFileSessionController.java | 20 +- ...raphOperatingPeriodsProcessingRequest.java | 19 + ...phOperatingPeriodsProcessingResultDto.java | 34 + .../PeriodizedDriverActivityInterval.java | 78 ++ .../model/ProcessedDrivingInterruption.java | 12 + .../model/ProcessedOperatingPeriod.java | 22 + .../ProcessedShiftDrivingEvaluation.java | 14 + .../model/ResolvedActivityInterval.java | 99 +++ .../model/ResolvedDriverTimeline.java | 15 + .../model/ResolvedVehicleUsageInterval.java | 43 ++ .../DriverCardXmlExtractionService.java | 86 +-- .../service/DriverTimelineBuilder.java | 212 ++++++ .../service/LegalRequirementsClient.java | 4 +- ...achographFileSessionProcessingService.java | 701 ++++++++++++++++++ .../service/TachographXmlParser.java | 12 +- .../VehicleUnitXmlExtractionService.java | 20 +- .../service/XmlExpressionEvaluator.java | 49 ++ src/main/resources/application.yml | 5 + .../TachographFileSessionControllerTest.java | 42 +- .../service/DriverTimelineBuilderTest.java | 111 +++ ...graphFileSessionProcessingServiceTest.java | 99 +++ .../service/TachographXmlParserTest.java | 10 + 24 files changed, 1718 insertions(+), 65 deletions(-) create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographOperatingPeriodsProcessingRequest.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographOperatingPeriodsProcessingResultDto.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/model/PeriodizedDriverActivityInterval.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedDrivingInterruption.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedOperatingPeriod.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedShiftDrivingEvaluation.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedActivityInterval.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedDriverTimeline.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedVehicleUsageInterval.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/service/XmlExpressionEvaluator.java create mode 100644 src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java create mode 100644 src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingServiceTest.java diff --git a/postman/eventhub-esper-poc.postman_collection.json b/postman/eventhub-esper-poc.postman_collection.json index 0b6d943..e4502d0 100644 --- a/postman/eventhub-esper-poc.postman_collection.json +++ b/postman/eventhub-esper-poc.postman_collection.json @@ -351,6 +351,38 @@ } } }, + { + "name": "Process tachograph file session operating periods", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"occurredFrom\": \"{{occurredFrom}}\",\n \"occurredTo\": \"{{occurredTo}}\",\n \"operatingSplitIdleHours\": 7,\n \"significantDrivingMinutes\": 3,\n \"mergeGapSeconds\": 0,\n \"gapDetectionToleranceSeconds\": 0\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}/drivers/{{driverKey}}/processing/operating-periods", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "tachograph-file-sessions", + "{{sessionId}}", + "drivers", + "{{driverKey}}", + "processing", + "operating-periods" + ] + } + } + }, { "name": "Delete tachograph file session", "request": { diff --git a/src/main/java/at/procon/eventhub/config/EventHubProperties.java b/src/main/java/at/procon/eventhub/config/EventHubProperties.java index 8305d74..e897a00 100644 --- a/src/main/java/at/procon/eventhub/config/EventHubProperties.java +++ b/src/main/java/at/procon/eventhub/config/EventHubProperties.java @@ -318,6 +318,7 @@ public class EventHubProperties { private int maxSessions = 100; private long maxFileSizeBytes = 20L * 1024L * 1024L; private final LegalRequirements legalRequirements = new LegalRequirements(); + private final Processing processing = new Processing(); public Duration getTtl() { return ttl; @@ -348,6 +349,49 @@ public class EventHubProperties { public LegalRequirements getLegalRequirements() { return legalRequirements; } + + public Processing getProcessing() { + return processing; + } + } + + public static class Processing { + private int operatingSplitIdleHours = 7; + private int significantDrivingMinutes = 3; + private int mergeGapSeconds = 0; + private int gapDetectionToleranceSeconds = 0; + + public int getOperatingSplitIdleHours() { + return operatingSplitIdleHours; + } + + public void setOperatingSplitIdleHours(int operatingSplitIdleHours) { + this.operatingSplitIdleHours = Math.max(1, operatingSplitIdleHours); + } + + public int getSignificantDrivingMinutes() { + return significantDrivingMinutes; + } + + public void setSignificantDrivingMinutes(int significantDrivingMinutes) { + this.significantDrivingMinutes = Math.max(1, significantDrivingMinutes); + } + + public int getMergeGapSeconds() { + return mergeGapSeconds; + } + + public void setMergeGapSeconds(int mergeGapSeconds) { + this.mergeGapSeconds = Math.max(0, mergeGapSeconds); + } + + public int getGapDetectionToleranceSeconds() { + return gapDetectionToleranceSeconds; + } + + public void setGapDetectionToleranceSeconds(int gapDetectionToleranceSeconds) { + this.gapDetectionToleranceSeconds = Math.max(0, gapDetectionToleranceSeconds); + } } public static class LegalRequirements { diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionController.java b/src/main/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionController.java index ea10ab8..01543dd 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionController.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionController.java @@ -2,9 +2,12 @@ package at.procon.eventhub.tachographfilesession.api; import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto; +import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest; +import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService; import java.util.UUID; import org.springframework.http.HttpStatus; @@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.multipart.MultipartFile; @RestController @@ -23,9 +27,14 @@ import org.springframework.web.multipart.MultipartFile; public class TachographFileSessionController { private final TachographFileSessionService service; + private final TachographFileSessionProcessingService processingService; - public TachographFileSessionController(TachographFileSessionService service) { + public TachographFileSessionController( + TachographFileSessionService service, + TachographFileSessionProcessingService processingService + ) { this.service = service; + this.processingService = processingService; } @PostMapping @@ -57,6 +66,15 @@ public class TachographFileSessionController { return ResponseEntity.ok(service.getDriver(sessionId, driverKey)); } + @PostMapping("/{sessionId}/drivers/{driverKey}/processing/operating-periods") + public ResponseEntity evaluateOperatingPeriods( + @PathVariable UUID sessionId, + @PathVariable String driverKey, + @RequestBody(required = false) TachographOperatingPeriodsProcessingRequest request + ) { + return ResponseEntity.ok(processingService.evaluateOperatingPeriods(sessionId, driverKey, request)); + } + @DeleteMapping("/{sessionId}") public ResponseEntity deleteSession(@PathVariable UUID sessionId) { return ResponseEntity.ok(service.deleteSession(sessionId)); diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographOperatingPeriodsProcessingRequest.java b/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographOperatingPeriodsProcessingRequest.java new file mode 100644 index 0000000..6609826 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographOperatingPeriodsProcessingRequest.java @@ -0,0 +1,19 @@ +package at.procon.eventhub.tachographfilesession.dto; + +import java.time.OffsetDateTime; + +public record TachographOperatingPeriodsProcessingRequest( + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo, + Integer operatingSplitIdleHours, + Integer significantDrivingMinutes, + Integer mergeGapSeconds, + Integer gapDetectionToleranceSeconds +) { + public TachographOperatingPeriodsProcessingRequest { + operatingSplitIdleHours = operatingSplitIdleHours == null ? null : Math.max(1, operatingSplitIdleHours); + significantDrivingMinutes = significantDrivingMinutes == null ? null : Math.max(1, significantDrivingMinutes); + mergeGapSeconds = mergeGapSeconds == null ? null : Math.max(0, mergeGapSeconds); + gapDetectionToleranceSeconds = gapDetectionToleranceSeconds == null ? null : Math.max(0, gapDetectionToleranceSeconds); + } +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographOperatingPeriodsProcessingResultDto.java b/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographOperatingPeriodsProcessingResultDto.java new file mode 100644 index 0000000..2a48859 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographOperatingPeriodsProcessingResultDto.java @@ -0,0 +1,34 @@ +package at.procon.eventhub.tachographfilesession.dto; + +import at.procon.eventhub.tachographfilesession.model.PeriodizedDriverActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ProcessedOperatingPeriod; +import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public record TachographOperatingPeriodsProcessingResultDto( + UUID sessionId, + String driverKey, + OffsetDateTime loadedFrom, + OffsetDateTime loadedTo, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo, + int activityIntervalCount, + int evaluationIntervalCount, + int periodizedIntervalCount, + int mergedIntervalCount, + int operatingPeriodCount, + int operatingSplitIdleHours, + int significantDrivingMinutes, + int mergeGapSeconds, + int gapDetectionToleranceSeconds, + ResolvedDriverTimeline timeline, + List evaluationIntervals, + List periodizedIntervals, + List mergedIntervals, + List operatingPeriods, + List notes +) { +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/PeriodizedDriverActivityInterval.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/PeriodizedDriverActivityInterval.java new file mode 100644 index 0000000..8025408 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/PeriodizedDriverActivityInterval.java @@ -0,0 +1,78 @@ +package at.procon.eventhub.tachographfilesession.model; + +import java.time.OffsetDateTime; +import java.util.List; + +public record PeriodizedDriverActivityInterval( + String intervalId, + OffsetDateTime from, + OffsetDateTime to, + long durationSeconds, + String activityType, + String slot, + String cardStatus, + String drivingStatus, + String registrationKey, + String vehicleKey, + String sourceKind, + List sourceIntervalIds, + boolean synthetic, + boolean clippedToRequestedPeriod, + String level, + long operatingPeriodNo, + OffsetDateTime operatingPeriodStartedAt, + boolean newOperatingPeriod, + Long gapSincePreviousActivitySeconds +) { + public PeriodizedDriverActivityInterval withTime(OffsetDateTime newFrom, OffsetDateTime newTo, boolean clipped) { + return new PeriodizedDriverActivityInterval( + intervalId, + newFrom, + newTo, + java.time.Duration.between(newFrom, newTo).getSeconds(), + activityType, + slot, + cardStatus, + drivingStatus, + registrationKey, + vehicleKey, + sourceKind, + sourceIntervalIds, + synthetic, + clipped, + level, + operatingPeriodNo, + operatingPeriodStartedAt, + newOperatingPeriod, + gapSincePreviousActivitySeconds + ); + } + + public PeriodizedDriverActivityInterval asMerged( + String mergedIntervalId, + OffsetDateTime newTo, + List mergedSourceIntervalIds + ) { + return new PeriodizedDriverActivityInterval( + mergedIntervalId, + from, + newTo, + java.time.Duration.between(from, newTo).getSeconds(), + activityType, + slot, + cardStatus, + drivingStatus, + registrationKey, + vehicleKey, + sourceKind, + mergedSourceIntervalIds, + synthetic, + clippedToRequestedPeriod, + "MERGED_ACTIVITY", + operatingPeriodNo, + operatingPeriodStartedAt, + newOperatingPeriod, + gapSincePreviousActivitySeconds + ); + } +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedDrivingInterruption.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedDrivingInterruption.java new file mode 100644 index 0000000..8faf387 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedDrivingInterruption.java @@ -0,0 +1,12 @@ +package at.procon.eventhub.tachographfilesession.model; + +import java.time.OffsetDateTime; + +public record ProcessedDrivingInterruption( + OffsetDateTime from, + OffsetDateTime to, + long durationSeconds, + String previousDrivingSourceIntervalId, + String nextDrivingSourceIntervalId +) { +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedOperatingPeriod.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedOperatingPeriod.java new file mode 100644 index 0000000..69e1c8c --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedOperatingPeriod.java @@ -0,0 +1,22 @@ +package at.procon.eventhub.tachographfilesession.model; + +import java.time.OffsetDateTime; +import java.util.List; + +public record ProcessedOperatingPeriod( + long operatingPeriodNo, + OffsetDateTime startedAt, + OffsetDateTime endedAt, + long durationSeconds, + String closedBy, + List rawActivities, + long breakRestSeconds, + long drivingSeconds, + long workSeconds, + long availabilitySeconds, + long unknownSeconds, + int intervalCount, + ProcessedShiftDrivingEvaluation drivingTimeInterruptionEvaluation, + boolean clippedToRequestedPeriod +) { +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedShiftDrivingEvaluation.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedShiftDrivingEvaluation.java new file mode 100644 index 0000000..817c243 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/ProcessedShiftDrivingEvaluation.java @@ -0,0 +1,14 @@ +package at.procon.eventhub.tachographfilesession.model; + +import java.time.OffsetDateTime; +import java.util.List; + +public record ProcessedShiftDrivingEvaluation( + int significantDrivingMinutes, + OffsetDateTime departureAt, + OffsetDateTime arrivalAt, + ResolvedActivityInterval firstSignificantDrivingPeriod, + ResolvedActivityInterval lastSignificantDrivingPeriod, + List interruptionsBetweenSignificantDrivingPeriods +) { +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedActivityInterval.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedActivityInterval.java new file mode 100644 index 0000000..02c9463 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedActivityInterval.java @@ -0,0 +1,99 @@ +package at.procon.eventhub.tachographfilesession.model; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.List; + +public record ResolvedActivityInterval( + String intervalId, + OffsetDateTime from, + OffsetDateTime to, + long durationSeconds, + String activityType, + String slot, + String cardStatus, + String drivingStatus, + String registrationKey, + String vehicleKey, + String sourceKind, + List sourceIntervalIds, + boolean synthetic, + boolean clippedToRequestedPeriod, + String level +) { + public static ResolvedActivityInterval raw( + String intervalId, + OffsetDateTime from, + OffsetDateTime to, + String activityType, + String slot, + String cardStatus, + String drivingStatus, + String registrationKey, + String vehicleKey, + String sourceKind, + List sourceIntervalIds + ) { + return new ResolvedActivityInterval( + intervalId, + from, + to, + Duration.between(from, to).getSeconds(), + activityType, + slot, + cardStatus, + drivingStatus, + registrationKey, + vehicleKey, + sourceKind, + sourceIntervalIds == null ? List.of() : List.copyOf(sourceIntervalIds), + false, + false, + "RAW_INTERVAL" + ); + } + + public ResolvedActivityInterval withTime(OffsetDateTime newFrom, OffsetDateTime newTo, boolean clipped) { + return new ResolvedActivityInterval( + intervalId, + newFrom, + newTo, + Duration.between(newFrom, newTo).getSeconds(), + activityType, + slot, + cardStatus, + drivingStatus, + registrationKey, + vehicleKey, + sourceKind, + sourceIntervalIds, + synthetic, + clipped, + level + ); + } + + public ResolvedActivityInterval asMerged( + String mergedIntervalId, + OffsetDateTime newTo, + List mergedSourceIntervalIds + ) { + return new ResolvedActivityInterval( + mergedIntervalId, + from, + newTo, + Duration.between(from, newTo).getSeconds(), + activityType, + slot, + cardStatus, + drivingStatus, + registrationKey, + vehicleKey, + sourceKind, + mergedSourceIntervalIds == null ? List.of() : List.copyOf(mergedSourceIntervalIds), + synthetic, + clippedToRequestedPeriod, + "MERGED_ACTIVITY" + ); + } +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedDriverTimeline.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedDriverTimeline.java new file mode 100644 index 0000000..a3111ba --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedDriverTimeline.java @@ -0,0 +1,15 @@ +package at.procon.eventhub.tachographfilesession.model; + +import java.time.OffsetDateTime; +import java.util.List; + +public record ResolvedDriverTimeline( + String sourceKind, + OffsetDateTime loadedFrom, + OffsetDateTime loadedTo, + List vehicleUsageIntervals, + List activityIntervals, + List supportEvents, + List warnings +) { +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedVehicleUsageInterval.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedVehicleUsageInterval.java new file mode 100644 index 0000000..bdb946c --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/ResolvedVehicleUsageInterval.java @@ -0,0 +1,43 @@ +package at.procon.eventhub.tachographfilesession.model; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.List; + +public record ResolvedVehicleUsageInterval( + String intervalId, + OffsetDateTime from, + OffsetDateTime to, + long durationSeconds, + Long odometerBeginKm, + Long odometerEndKm, + String registrationKey, + String vehicleKey, + String sourceKind, + List sourceIntervalIds +) { + public static ResolvedVehicleUsageInterval resolved( + String intervalId, + OffsetDateTime from, + OffsetDateTime to, + Long odometerBeginKm, + Long odometerEndKm, + String registrationKey, + String vehicleKey, + String sourceKind, + List sourceIntervalIds + ) { + return new ResolvedVehicleUsageInterval( + intervalId, + from, + to, + Duration.between(from, to).getSeconds(), + odometerBeginKm, + odometerEndKm, + registrationKey, + vehicleKey, + sourceKind, + sourceIntervalIds == null ? List.of() : List.copyOf(sourceIntervalIds) + ); + } +} 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 1bc5902..c822ae7 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionService.java @@ -19,21 +19,13 @@ import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Set; import java.util.TreeSet; import java.util.UUID; -import javax.xml.xpath.XPath; -import javax.xml.xpath.XPathConstants; -import javax.xml.xpath.XPathExpressionException; -import javax.xml.xpath.XPathFactory; import org.springframework.stereotype.Component; import org.w3c.dom.Document; import org.w3c.dom.Element; -import org.w3c.dom.Node; import org.w3c.dom.NodeList; @Component @@ -41,6 +33,7 @@ public class DriverCardXmlExtractionService { private final DriverKeyFactory driverKeyFactory; private final VehicleKeyFactory vehicleKeyFactory; + private final XmlExpressionEvaluator xml = new XmlExpressionEvaluator(); public DriverCardXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) { this.driverKeyFactory = driverKeyFactory; @@ -276,41 +269,63 @@ public class DriverCardXmlExtractionService { List vehicleUsageIntervals ) { List result = new ArrayList<>(activityIntervals.size()); + int usageStartIndex = 0; for (ExtractedCardActivityInterval interval : activityIntervals) { - result.addAll(splitByVehicleCoverage(interval, vehicleUsageIntervals)); + while (usageStartIndex < vehicleUsageIntervals.size() + && !endExclusive(vehicleUsageIntervals.get(usageStartIndex).to()).isAfter(interval.from())) { + usageStartIndex++; + } + + int overlapEndIndex = usageStartIndex; + while (overlapEndIndex < vehicleUsageIntervals.size() + && vehicleUsageIntervals.get(overlapEndIndex).from().isBefore(interval.to())) { + overlapEndIndex++; + } + + result.addAll(splitByVehicleCoverage( + interval, + vehicleUsageIntervals.subList(usageStartIndex, overlapEndIndex) + )); } return result; } private List splitByVehicleCoverage( ExtractedCardActivityInterval interval, - List vehicleUsageIntervals + List overlappingUsages ) { - Set cutPoints = new TreeSet<>(); + TreeSet cutPoints = new TreeSet<>(); cutPoints.add(interval.from()); cutPoints.add(interval.to()); - for (ExtractedCardVehicleUsageInterval usage : vehicleUsageIntervals) { - if (!usage.to().isBefore(interval.from()) && !usage.from().isAfter(interval.to())) { - if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) { - cutPoints.add(usage.from()); - } - OffsetDateTime usageEndExclusive = usage.to().plusSeconds(1); - if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) { - cutPoints.add(usageEndExclusive); - } + for (ExtractedCardVehicleUsageInterval usage : overlappingUsages) { + if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) { + cutPoints.add(usage.from()); + } + OffsetDateTime usageEndExclusive = endExclusive(usage.to()); + if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) { + cutPoints.add(usageEndExclusive); } } List orderedCutPoints = List.copyOf(cutPoints); List segments = new ArrayList<>(Math.max(1, orderedCutPoints.size() - 1)); + int coverageIndex = 0; for (int i = 0; i < orderedCutPoints.size() - 1; i++) { OffsetDateTime segmentFrom = orderedCutPoints.get(i); OffsetDateTime segmentTo = orderedCutPoints.get(i + 1); if (!segmentFrom.isBefore(segmentTo)) { continue; } - ExtractedCardVehicleUsageInterval covering = findVehicleCoverage(segmentFrom, vehicleUsageIntervals); + + while (coverageIndex < overlappingUsages.size() + && !endExclusive(overlappingUsages.get(coverageIndex).to()).isAfter(segmentFrom)) { + coverageIndex++; + } + ExtractedCardVehicleUsageInterval covering = coverageIndex < overlappingUsages.size() + && covers(overlappingUsages.get(coverageIndex), segmentFrom) + ? overlappingUsages.get(coverageIndex) + : null; String intervalId = orderedCutPoints.size() == 2 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1); segments.add(new ExtractedCardActivityInterval( intervalId, @@ -328,14 +343,12 @@ public class DriverCardXmlExtractionService { return segments; } - private ExtractedCardVehicleUsageInterval findVehicleCoverage( - OffsetDateTime timestamp, - List vehicleUsageIntervals - ) { - return vehicleUsageIntervals.stream() - .filter(usage -> !usage.from().isAfter(timestamp) && timestamp.isBefore(usage.to().plusSeconds(1))) - .findFirst() - .orElse(null); + private boolean covers(ExtractedCardVehicleUsageInterval usage, OffsetDateTime timestamp) { + return !usage.from().isAfter(timestamp) && timestamp.isBefore(endExclusive(usage.to())); + } + + private OffsetDateTime endExclusive(OffsetDateTime timestamp) { + return timestamp.plusSeconds(1); } private Element firstElement(Object node, String expression) { @@ -347,22 +360,11 @@ public class DriverCardXmlExtractionService { } private NodeList nodes(Object node, String expression) { - try { - XPath xpath = XPathFactory.newInstance().newXPath(); - return (NodeList) xpath.evaluate(expression, node, XPathConstants.NODESET); - } catch (XPathExpressionException e) { - throw new IllegalStateException("Invalid XPath expression: " + expression, e); - } + return xml.nodes(node, expression); } private String text(Object node, String expression) { - try { - XPath xpath = XPathFactory.newInstance().newXPath(); - String value = xpath.evaluate(expression, node); - return value == null || value.isBlank() ? null : value.trim(); - } catch (XPathExpressionException e) { - throw new IllegalStateException("Invalid XPath expression: " + expression, e); - } + return xml.text(node, expression); } private OffsetDateTime offsetDateTime(String value) { diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java new file mode 100644 index 0000000..316423e --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java @@ -0,0 +1,212 @@ +package at.procon.eventhub.tachographfilesession.service; + +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent; +import at.procon.eventhub.tachographfilesession.model.ExtractionWarning; +import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import org.springframework.stereotype.Component; + +@Component +public class DriverTimelineBuilder { + + public ResolvedDriverTimeline build(TachographFileSession session, DriverExtractionSession driverSession) { + String sourceKind = session.metadata().driverCardFile() ? "DRIVER_CARD" : "VEHICLE_UNIT"; + List vehicleUsageIntervals = mergeVehicleUsageIntervals(driverSession.cardVehicleUsageIntervals(), sourceKind); + List activityIntervals = resolveActivities(driverSession.cardActivityIntervals(), sourceKind); + List supportEvents = driverSession.supportEvents().stream() + .sorted(Comparator.comparing(ExtractedSupportEvent::occurredAt) + .thenComparing(ExtractedSupportEvent::eventDomain, Comparator.nullsLast(String::compareTo)) + .thenComparing(ExtractedSupportEvent::eventId, Comparator.nullsLast(String::compareTo))) + .toList(); + OffsetDateTime loadedFrom = minTimestamp(vehicleUsageIntervals, activityIntervals, supportEvents); + OffsetDateTime loadedTo = maxTimestamp(vehicleUsageIntervals, activityIntervals, supportEvents); + List warnings = mergeWarnings(session.warnings(), driverSession.warnings()); + return new ResolvedDriverTimeline( + sourceKind, + loadedFrom, + loadedTo, + vehicleUsageIntervals, + activityIntervals, + supportEvents, + warnings + ); + } + + private List mergeVehicleUsageIntervals( + List rawIntervals, + String sourceKind + ) { + if (rawIntervals == null || rawIntervals.isEmpty()) { + return List.of(); + } + List sorted = rawIntervals.stream() + .filter(interval -> interval.from() != null && interval.to() != null && interval.to().isAfter(interval.from())) + .map(interval -> ResolvedVehicleUsageInterval.resolved( + interval.intervalId(), + interval.from(), + interval.to(), + interval.odometerBeginKm(), + interval.odometerEndKm(), + interval.registrationKey(), + interval.vehicleKey(), + sourceKind, + List.of(interval.intervalId()) + )) + .sorted(Comparator.comparing(ResolvedVehicleUsageInterval::from) + .thenComparing(ResolvedVehicleUsageInterval::to)) + .toList(); + if (sorted.isEmpty()) { + return List.of(); + } + + List result = new ArrayList<>(); + ResolvedVehicleUsageInterval current = sorted.get(0); + List currentSources = new ArrayList<>(current.sourceIntervalIds()); + for (int i = 1; i < sorted.size(); i++) { + ResolvedVehicleUsageInterval next = sorted.get(i); + if (canMerge(current, next)) { + currentSources.addAll(next.sourceIntervalIds()); + current = ResolvedVehicleUsageInterval.resolved( + current.intervalId() + "+" + next.intervalId(), + current.from(), + max(current.to(), next.to()), + current.odometerBeginKm(), + next.odometerEndKm() != null ? next.odometerEndKm() : current.odometerEndKm(), + current.registrationKey(), + current.vehicleKey(), + sourceKind, + currentSources + ); + } else { + result.add(current); + current = next; + currentSources = new ArrayList<>(current.sourceIntervalIds()); + } + } + result.add(current); + return result; + } + + private boolean canMerge(ResolvedVehicleUsageInterval left, ResolvedVehicleUsageInterval right) { + return Objects.equals(left.registrationKey(), right.registrationKey()) + && Objects.equals(left.vehicleKey(), right.vehicleKey()) + && !right.from().isAfter(left.to().plusSeconds(1)); + } + + private List resolveActivities( + List rawIntervals, + String sourceKind + ) { + if (rawIntervals == null || rawIntervals.isEmpty()) { + return List.of(); + } + return rawIntervals.stream() + .filter(interval -> interval.from() != null && interval.to() != null && interval.to().isAfter(interval.from())) + .map(interval -> ResolvedActivityInterval.raw( + interval.intervalId(), + interval.from(), + interval.to(), + normalizeActivity(interval.activityType()), + interval.slot(), + interval.cardStatus(), + interval.drivingStatus(), + interval.registrationKey(), + interval.vehicleKey(), + sourceKind, + List.of(interval.intervalId()) + )) + .sorted(Comparator.comparing(ResolvedActivityInterval::from) + .thenComparing(ResolvedActivityInterval::to) + .thenComparing(ResolvedActivityInterval::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private String normalizeActivity(String activityType) { + if (activityType == null || activityType.isBlank()) { + return "UNKNOWN"; + } + if ("UNKNOWN_ACTIVITY".equals(activityType)) { + return "UNKNOWN"; + } + return activityType; + } + + private OffsetDateTime minTimestamp( + List vehicleUsageIntervals, + List activityIntervals, + List supportEvents + ) { + OffsetDateTime min = null; + for (ResolvedVehicleUsageInterval interval : vehicleUsageIntervals) { + min = min(min, interval.from()); + } + for (ResolvedActivityInterval interval : activityIntervals) { + min = min(min, interval.from()); + } + for (ExtractedSupportEvent supportEvent : supportEvents) { + min = min(min, supportEvent.occurredAt()); + } + return min; + } + + private OffsetDateTime maxTimestamp( + List vehicleUsageIntervals, + List activityIntervals, + List supportEvents + ) { + OffsetDateTime max = null; + for (ResolvedVehicleUsageInterval interval : vehicleUsageIntervals) { + max = max(max, interval.to()); + } + for (ResolvedActivityInterval interval : activityIntervals) { + max = max(max, interval.to()); + } + for (ExtractedSupportEvent supportEvent : supportEvents) { + max = max(max, supportEvent.occurredAt()); + } + return max; + } + + private List mergeWarnings(List sessionWarnings, List driverWarnings) { + LinkedHashSet merged = new LinkedHashSet<>(); + if (sessionWarnings != null) { + merged.addAll(sessionWarnings); + } + if (driverWarnings != null) { + merged.addAll(driverWarnings); + } + return List.copyOf(merged); + } + + private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isBefore(right) ? left : right; + } + + private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isAfter(right) ? left : right; + } +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/LegalRequirementsClient.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/LegalRequirementsClient.java index ebb59bc..f21ec89 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/LegalRequirementsClient.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/LegalRequirementsClient.java @@ -60,9 +60,9 @@ public class LegalRequirementsClient { throw new LegalRequirementsUploadException("LegalRequirements upload failed with status " + response.statusCode()); } JsonNode root = objectMapper.readTree(response.body()); - String dataPackageId = text(root, "DataPackageID"); + String dataPackageId = text(root, "ID"); if (dataPackageId == null) { - dataPackageId = text(root, "DataPackageId"); + dataPackageId = text(root, "Id"); } if (dataPackageId == null && root.has("value") && root.get("value").isObject()) { dataPackageId = text(root.get("value"), "DataPackageID"); diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java new file mode 100644 index 0000000..6073ae2 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java @@ -0,0 +1,701 @@ +package at.procon.eventhub.tachographfilesession.service; + +import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest; +import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto; +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.PeriodizedDriverActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ProcessedDrivingInterruption; +import at.procon.eventhub.tachographfilesession.model.ProcessedOperatingPeriod; +import at.procon.eventhub.tachographfilesession.model.ProcessedShiftDrivingEvaluation; +import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.stereotype.Service; + +@Service +public class TachographFileSessionProcessingService { + + private final TachographFileSessionRepository repository; + private final DriverTimelineBuilder driverTimelineBuilder; + private final EventHubProperties properties; + + public TachographFileSessionProcessingService( + TachographFileSessionRepository repository, + DriverTimelineBuilder driverTimelineBuilder, + EventHubProperties properties + ) { + this.repository = repository; + this.driverTimelineBuilder = driverTimelineBuilder; + this.properties = properties; + } + + public TachographOperatingPeriodsProcessingResultDto evaluateOperatingPeriods( + UUID sessionId, + String driverKey, + TachographOperatingPeriodsProcessingRequest request + ) { + TachographOperatingPeriodsProcessingRequest effectiveRequest = request == null + ? new TachographOperatingPeriodsProcessingRequest(null, null, null, null, null, null) + : request; + TachographFileSession session = repository.find(sessionId) + .orElseThrow(() -> new TachographFileSessionNotFoundException(sessionId)); + DriverExtractionSession driver = session.driversByKey().get(driverKey); + if (driver == null) { + throw new DriverNotFoundInSessionException(sessionId, driverKey); + } + + ResolvedDriverTimeline timeline = driverTimelineBuilder.build(session, driver); + OffsetDateTime loadedFrom = timeline.loadedFrom(); + OffsetDateTime loadedTo = timeline.loadedTo(); + OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null ? loadedFrom : utc(effectiveRequest.occurredFrom()); + OffsetDateTime requestedTo = effectiveRequest.occurredTo() == null ? loadedTo : utc(effectiveRequest.occurredTo()); + if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) { + throw new IllegalArgumentException("occurredTo must not be before occurredFrom."); + } + + Duration operatingSplitIdleThreshold = Duration.ofHours(resolveOperatingSplitIdleHours(effectiveRequest)); + Duration significantDrivingThreshold = Duration.ofMinutes(resolveSignificantDrivingMinutes(effectiveRequest)); + Duration mergeGapTolerance = Duration.ofSeconds(resolveMergeGapSeconds(effectiveRequest)); + Duration gapDetectionTolerance = Duration.ofSeconds(resolveGapDetectionToleranceSeconds(effectiveRequest)); + + List knownLoadedIntervals = sortedPositiveIntervals(timeline.activityIntervals()); + List evaluationLoadedIntervals = synthesizeUnknownGaps(knownLoadedIntervals, gapDetectionTolerance); + PeriodizationResult periodization = periodize(evaluationLoadedIntervals, operatingSplitIdleThreshold); + List mergedLoadedIntervals = mergeConsecutiveActivities( + periodization.periodizedIntervals(), + mergeGapTolerance + ); + + List evaluationIntervals = clipResolvedIntervals(evaluationLoadedIntervals, requestedFrom, requestedTo); + List periodizedIntervals = clipPeriodizedIntervals(periodization.periodizedIntervals(), requestedFrom, requestedTo); + List mergedIntervals = clipPeriodizedIntervals(mergedLoadedIntervals, requestedFrom, requestedTo); + List operatingPeriods = buildOperatingPeriods( + periodization.closedPeriods(), + periodizedIntervals, + knownLoadedIntervals, + requestedFrom, + requestedTo, + mergeGapTolerance, + significantDrivingThreshold + ); + + return new TachographOperatingPeriodsProcessingResultDto( + sessionId, + driverKey, + loadedFrom, + loadedTo, + requestedFrom, + requestedTo, + timeline.activityIntervals().size(), + evaluationIntervals.size(), + periodizedIntervals.size(), + mergedIntervals.size(), + operatingPeriods.size(), + resolveOperatingSplitIdleHours(effectiveRequest), + resolveSignificantDrivingMinutes(effectiveRequest), + resolveMergeGapSeconds(effectiveRequest), + resolveGapDetectionToleranceSeconds(effectiveRequest), + timeline, + evaluationIntervals, + periodizedIntervals, + mergedIntervals, + operatingPeriods, + notes() + ); + } + + private List synthesizeUnknownGaps( + List knownIntervals, + Duration gapDetectionTolerance + ) { + List allKnown = sortedPositiveIntervals(knownIntervals); + List nonRestActivities = allKnown.stream() + .filter(interval -> !"BREAK_REST".equals(interval.activityType())) + .toList(); + if (nonRestActivities.isEmpty()) { + return List.of(); + } + + List result = new ArrayList<>(); + for (int index = 0; index < nonRestActivities.size(); index++) { + ResolvedActivityInterval current = nonRestActivities.get(index); + result.add(current); + if (index + 1 >= nonRestActivities.size()) { + continue; + } + ResolvedActivityInterval next = nonRestActivities.get(index + 1); + if (!next.from().isAfter(current.to())) { + continue; + } + OffsetDateTime gapStart = current.to(); + OffsetDateTime gapEnd = next.from(); + List uncoveredGapSegments = subtractCoverage( + unknownGapTemplate(current, next, gapStart, gapEnd), + allKnown + ); + for (ResolvedActivityInterval gap : uncoveredGapSegments) { + if (gap.durationSeconds() > gapDetectionTolerance.getSeconds()) { + result.add(gap); + } + } + } + return result.stream() + .sorted(Comparator.comparing(ResolvedActivityInterval::from) + .thenComparing(ResolvedActivityInterval::to) + .thenComparing(ResolvedActivityInterval::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private PeriodizationResult periodize( + List intervals, + Duration operatingSplitIdleThreshold + ) { + List sorted = sortedPositiveIntervals(intervals); + if (sorted.isEmpty()) { + return new PeriodizationResult(List.of(), List.of()); + } + + List periodizedIntervals = new ArrayList<>(); + List closedPeriods = new ArrayList<>(); + boolean hasOpenPeriod = false; + long operatingPeriodNo = 0L; + OffsetDateTime operatingPeriodStartedAt = null; + OffsetDateTime lastKnownActivityEndAt = null; + + for (ResolvedActivityInterval interval : sorted) { + if ("UNKNOWN".equals(interval.activityType())) { + if (!hasOpenPeriod) { + continue; + } + if (interval.durationSeconds() >= operatingSplitIdleThreshold.getSeconds()) { + closedPeriods.add(closeCurrent(operatingPeriodNo, operatingPeriodStartedAt, lastKnownActivityEndAt, "UNKNOWN_GAP")); + hasOpenPeriod = false; + operatingPeriodStartedAt = null; + lastKnownActivityEndAt = null; + continue; + } + periodizedIntervals.add(periodized(interval, operatingPeriodNo, operatingPeriodStartedAt, false, + Math.max(0L, Duration.between(lastKnownActivityEndAt, interval.from()).getSeconds()))); + continue; + } + + if (!hasOpenPeriod) { + operatingPeriodNo = operatingPeriodNo < 1 ? 1 : operatingPeriodNo + 1; + hasOpenPeriod = true; + operatingPeriodStartedAt = interval.from(); + lastKnownActivityEndAt = interval.to(); + periodizedIntervals.add(periodized(interval, operatingPeriodNo, operatingPeriodStartedAt, true, null)); + continue; + } + + long gapSeconds = Math.max(0L, Duration.between(lastKnownActivityEndAt, interval.from()).getSeconds()); + if (gapSeconds >= operatingSplitIdleThreshold.getSeconds()) { + closedPeriods.add(closeCurrent(operatingPeriodNo, operatingPeriodStartedAt, lastKnownActivityEndAt, "IDLE_GAP")); + operatingPeriodNo++; + hasOpenPeriod = true; + operatingPeriodStartedAt = interval.from(); + lastKnownActivityEndAt = interval.to(); + periodizedIntervals.add(periodized(interval, operatingPeriodNo, operatingPeriodStartedAt, true, gapSeconds)); + continue; + } + + periodizedIntervals.add(periodized(interval, operatingPeriodNo, operatingPeriodStartedAt, false, gapSeconds)); + if (interval.to().isAfter(lastKnownActivityEndAt)) { + lastKnownActivityEndAt = interval.to(); + } + } + + if (hasOpenPeriod) { + closedPeriods.add(closeCurrent(operatingPeriodNo, operatingPeriodStartedAt, lastKnownActivityEndAt, "FLUSH")); + } + + return new PeriodizationResult( + periodizedIntervals.stream() + .sorted(Comparator.comparing(PeriodizedDriverActivityInterval::from) + .thenComparing(PeriodizedDriverActivityInterval::to) + .thenComparing(PeriodizedDriverActivityInterval::activityType, Comparator.nullsLast(String::compareTo))) + .toList(), + closedPeriods.stream() + .sorted(Comparator.comparing(ClosedOperatingPeriod::startedAt)) + .toList() + ); + } + + private PeriodizedDriverActivityInterval periodized( + ResolvedActivityInterval interval, + long operatingPeriodNo, + OffsetDateTime operatingPeriodStartedAt, + boolean newOperatingPeriod, + Long gapSincePreviousActivitySeconds + ) { + return new PeriodizedDriverActivityInterval( + interval.intervalId(), + interval.from(), + interval.to(), + interval.durationSeconds(), + interval.activityType(), + interval.slot(), + interval.cardStatus(), + interval.drivingStatus(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + interval.sourceIntervalIds(), + interval.synthetic(), + interval.clippedToRequestedPeriod(), + interval.level(), + operatingPeriodNo, + operatingPeriodStartedAt, + newOperatingPeriod, + gapSincePreviousActivitySeconds + ); + } + + private ClosedOperatingPeriod closeCurrent( + long operatingPeriodNo, + OffsetDateTime operatingPeriodStartedAt, + OffsetDateTime lastKnownActivityEndAt, + String closedBy + ) { + return new ClosedOperatingPeriod( + operatingPeriodNo, + operatingPeriodStartedAt, + lastKnownActivityEndAt, + Duration.between(operatingPeriodStartedAt, lastKnownActivityEndAt).getSeconds(), + closedBy + ); + } + + private List mergeConsecutiveActivities( + List intervals, + Duration mergeGapTolerance + ) { + if (intervals == null || intervals.isEmpty()) { + return List.of(); + } + List sorted = intervals.stream() + .filter(interval -> interval.to().isAfter(interval.from())) + .sorted(Comparator.comparing(PeriodizedDriverActivityInterval::from) + .thenComparing(PeriodizedDriverActivityInterval::to) + .thenComparing(PeriodizedDriverActivityInterval::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + + List result = new ArrayList<>(); + PeriodizedDriverActivityInterval current = null; + List currentSources = new ArrayList<>(); + for (PeriodizedDriverActivityInterval next : sorted) { + if (current == null) { + current = next; + currentSources = new ArrayList<>(next.sourceIntervalIds()); + continue; + } + if (canMerge(current, next, mergeGapTolerance)) { + currentSources.addAll(next.sourceIntervalIds()); + current = current.asMerged(current.intervalId() + "+" + next.intervalId(), max(current.to(), next.to()), currentSources); + } else { + result.add(current.asMerged(current.intervalId(), current.to(), currentSources)); + current = next; + currentSources = new ArrayList<>(next.sourceIntervalIds()); + } + } + if (current != null) { + result.add(current.asMerged(current.intervalId(), current.to(), currentSources)); + } + return result; + } + + private boolean canMerge( + PeriodizedDriverActivityInterval left, + PeriodizedDriverActivityInterval right, + Duration mergeGapTolerance + ) { + long gapSeconds = Duration.between(left.to(), right.from()).getSeconds(); + return left.operatingPeriodNo() == right.operatingPeriodNo() + && Objects.equals(left.activityType(), right.activityType()) + && Objects.equals(left.slot(), right.slot()) + && Objects.equals(left.cardStatus(), right.cardStatus()) + && Objects.equals(left.drivingStatus(), right.drivingStatus()) + && Objects.equals(left.registrationKey(), right.registrationKey()) + && Objects.equals(left.vehicleKey(), right.vehicleKey()) + && Objects.equals(left.sourceKind(), right.sourceKind()) + && left.synthetic() == right.synthetic() + && gapSeconds <= mergeGapTolerance.getSeconds(); + } + + private List buildOperatingPeriods( + List closedPeriods, + List clippedPeriodizedIntervals, + List knownLoadedIntervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo, + Duration mergeGapTolerance, + Duration significantDrivingThreshold + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + + LinkedHashMap> intervalsByPeriod = new LinkedHashMap<>(); + for (PeriodizedDriverActivityInterval interval : clippedPeriodizedIntervals) { + intervalsByPeriod.computeIfAbsent(interval.operatingPeriodNo(), ignored -> new ArrayList<>()).add(interval); + } + + List result = new ArrayList<>(); + for (ClosedOperatingPeriod closedPeriod : closedPeriods) { + if (!closedPeriod.endedAt().isAfter(requestedFrom) || !closedPeriod.startedAt().isBefore(requestedTo)) { + continue; + } + OffsetDateTime start = max(closedPeriod.startedAt(), requestedFrom); + OffsetDateTime end = min(closedPeriod.endedAt(), requestedTo); + if (!end.isAfter(start)) { + continue; + } + + List intervals = intervalsByPeriod.getOrDefault(closedPeriod.operatingPeriodNo(), List.of()); + List rawActivities = clipResolvedIntervals(knownLoadedIntervals, start, end); + long breakRestSeconds = rawActivities.stream() + .filter(activity -> "BREAK_REST".equals(activity.activityType())) + .mapToLong(ResolvedActivityInterval::durationSeconds) + .sum(); + ProcessedShiftDrivingEvaluation drivingEvaluation = evaluateSignificantDriving( + rawActivities, + mergeGapTolerance, + significantDrivingThreshold + ); + result.add(new ProcessedOperatingPeriod( + closedPeriod.operatingPeriodNo(), + start, + end, + Duration.between(start, end).getSeconds(), + closedPeriod.closedBy(), + rawActivities, + breakRestSeconds, + sumActivitySeconds(intervals, "DRIVE"), + sumActivitySeconds(intervals, "WORK"), + sumActivitySeconds(intervals, "AVAILABILITY"), + sumActivitySeconds(intervals, "UNKNOWN"), + intervals.size(), + drivingEvaluation, + !start.equals(closedPeriod.startedAt()) || !end.equals(closedPeriod.endedAt()) + )); + } + return result; + } + + private ProcessedShiftDrivingEvaluation evaluateSignificantDriving( + List rawActivities, + Duration mergeGapTolerance, + Duration significantDrivingThreshold + ) { + if (rawActivities.isEmpty()) { + return new ProcessedShiftDrivingEvaluation( + (int) significantDrivingThreshold.toMinutes(), + null, + null, + null, + null, + List.of() + ); + } + List mergedActivities = mergeConsecutiveRawActivities(rawActivities, mergeGapTolerance); + List significantDrivingPeriods = mergedActivities.stream() + .filter(activity -> "DRIVE".equals(activity.activityType())) + .filter(activity -> activity.durationSeconds() > significantDrivingThreshold.getSeconds()) + .sorted(Comparator.comparing(ResolvedActivityInterval::from)) + .toList(); + if (significantDrivingPeriods.isEmpty()) { + return new ProcessedShiftDrivingEvaluation( + (int) significantDrivingThreshold.toMinutes(), + null, + null, + null, + null, + List.of() + ); + } + + List interruptions = new ArrayList<>(); + for (int i = 1; i < significantDrivingPeriods.size(); i++) { + ResolvedActivityInterval previous = significantDrivingPeriods.get(i - 1); + ResolvedActivityInterval next = significantDrivingPeriods.get(i); + if (next.from().isAfter(previous.to())) { + interruptions.add(new ProcessedDrivingInterruption( + previous.to(), + next.from(), + Duration.between(previous.to(), next.from()).getSeconds(), + previous.sourceIntervalIds().isEmpty() ? null : previous.sourceIntervalIds().get(previous.sourceIntervalIds().size() - 1), + next.sourceIntervalIds().isEmpty() ? null : next.sourceIntervalIds().get(0) + )); + } + } + + ResolvedActivityInterval first = significantDrivingPeriods.get(0); + ResolvedActivityInterval last = significantDrivingPeriods.get(significantDrivingPeriods.size() - 1); + return new ProcessedShiftDrivingEvaluation( + (int) significantDrivingThreshold.toMinutes(), + first.from(), + last.to(), + first, + last, + interruptions + ); + } + + private List mergeConsecutiveRawActivities( + List intervals, + Duration mergeGapTolerance + ) { + if (intervals == null || intervals.isEmpty()) { + return List.of(); + } + List sorted = intervals.stream() + .filter(interval -> interval.to().isAfter(interval.from())) + .sorted(Comparator.comparing(ResolvedActivityInterval::from) + .thenComparing(ResolvedActivityInterval::to) + .thenComparing(ResolvedActivityInterval::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + + List result = new ArrayList<>(); + ResolvedActivityInterval current = null; + List currentSources = new ArrayList<>(); + for (ResolvedActivityInterval next : sorted) { + if (current == null) { + current = next; + currentSources = new ArrayList<>(next.sourceIntervalIds()); + continue; + } + long gapSeconds = Duration.between(current.to(), next.from()).getSeconds(); + if (Objects.equals(current.activityType(), next.activityType()) + && Objects.equals(current.slot(), next.slot()) + && Objects.equals(current.cardStatus(), next.cardStatus()) + && Objects.equals(current.drivingStatus(), next.drivingStatus()) + && Objects.equals(current.registrationKey(), next.registrationKey()) + && Objects.equals(current.vehicleKey(), next.vehicleKey()) + && Objects.equals(current.sourceKind(), next.sourceKind()) + && current.synthetic() == next.synthetic() + && gapSeconds <= mergeGapTolerance.getSeconds()) { + currentSources.addAll(next.sourceIntervalIds()); + current = current.asMerged(current.intervalId() + "+" + next.intervalId(), max(current.to(), next.to()), currentSources); + } else { + result.add(current.asMerged(current.intervalId(), current.to(), currentSources)); + current = next; + currentSources = new ArrayList<>(next.sourceIntervalIds()); + } + } + if (current != null) { + result.add(current.asMerged(current.intervalId(), current.to(), currentSources)); + } + return result; + } + + private long sumActivitySeconds(List intervals, String activityType) { + return intervals.stream() + .filter(interval -> activityType.equals(interval.activityType())) + .mapToLong(PeriodizedDriverActivityInterval::durationSeconds) + .sum(); + } + + private List clipResolvedIntervals( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.from(), requestedFrom); + OffsetDateTime end = min(interval.to(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + boolean clipped = interval.clippedToRequestedPeriod() + || !start.equals(interval.from()) + || !end.equals(interval.to()); + return interval.withTime(start, end, clipped); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(ResolvedActivityInterval::from) + .thenComparing(ResolvedActivityInterval::to) + .thenComparing(ResolvedActivityInterval::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List clipPeriodizedIntervals( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.from(), requestedFrom); + OffsetDateTime end = min(interval.to(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + boolean clipped = interval.clippedToRequestedPeriod() + || !start.equals(interval.from()) + || !end.equals(interval.to()); + return interval.withTime(start, end, clipped); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(PeriodizedDriverActivityInterval::from) + .thenComparing(PeriodizedDriverActivityInterval::to) + .thenComparing(PeriodizedDriverActivityInterval::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private ResolvedActivityInterval unknownGapTemplate( + ResolvedActivityInterval previous, + ResolvedActivityInterval next, + OffsetDateTime gapStart, + OffsetDateTime gapEnd + ) { + return new ResolvedActivityInterval( + "UNKNOWN-" + gapStart.toEpochSecond(), + gapStart, + gapEnd, + Duration.between(gapStart, gapEnd).getSeconds(), + "UNKNOWN", + Objects.equals(previous.slot(), next.slot()) ? previous.slot() : null, + previous.cardStatus(), + "UNKNOWN", + Objects.equals(previous.registrationKey(), next.registrationKey()) ? previous.registrationKey() : null, + Objects.equals(previous.vehicleKey(), next.vehicleKey()) ? previous.vehicleKey() : null, + "SYNTHETIC_GAP", + List.of(), + true, + false, + "UNKNOWN_GAP" + ); + } + + private List subtractCoverage( + ResolvedActivityInterval candidate, + List coverage + ) { + List result = new ArrayList<>(); + OffsetDateTime cursor = candidate.from(); + for (ResolvedActivityInterval covered : coverage) { + if (!covered.to().isAfter(cursor)) { + continue; + } + if (!covered.from().isBefore(candidate.to())) { + break; + } + OffsetDateTime overlapStart = max(cursor, covered.from()); + if (overlapStart.isAfter(cursor)) { + result.add(candidate.withTime(cursor, overlapStart, candidate.clippedToRequestedPeriod())); + } + if (covered.to().isAfter(cursor)) { + cursor = max(cursor, covered.to()); + } + if (!candidate.to().isAfter(cursor)) { + break; + } + } + if (candidate.to().isAfter(cursor)) { + result.add(candidate.withTime(cursor, candidate.to(), candidate.clippedToRequestedPeriod())); + } + return result.stream() + .filter(interval -> interval.to().isAfter(interval.from())) + .toList(); + } + + private List sortedPositiveIntervals(List intervals) { + if (intervals == null || intervals.isEmpty()) { + return List.of(); + } + return intervals.stream() + .filter(interval -> interval.to().isAfter(interval.from())) + .sorted(Comparator.comparing(ResolvedActivityInterval::from) + .thenComparing(ResolvedActivityInterval::to) + .thenComparing(ResolvedActivityInterval::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private int resolveOperatingSplitIdleHours(TachographOperatingPeriodsProcessingRequest request) { + return request.operatingSplitIdleHours() == null + ? properties.getTachographFileSession().getProcessing().getOperatingSplitIdleHours() + : request.operatingSplitIdleHours(); + } + + private int resolveSignificantDrivingMinutes(TachographOperatingPeriodsProcessingRequest request) { + return request.significantDrivingMinutes() == null + ? properties.getTachographFileSession().getProcessing().getSignificantDrivingMinutes() + : request.significantDrivingMinutes(); + } + + private int resolveMergeGapSeconds(TachographOperatingPeriodsProcessingRequest request) { + return request.mergeGapSeconds() == null + ? properties.getTachographFileSession().getProcessing().getMergeGapSeconds() + : request.mergeGapSeconds(); + } + + private int resolveGapDetectionToleranceSeconds(TachographOperatingPeriodsProcessingRequest request) { + return request.gapDetectionToleranceSeconds() == null + ? properties.getTachographFileSession().getProcessing().getGapDetectionToleranceSeconds() + : request.gapDetectionToleranceSeconds(); + } + + private List notes() { + return List.of( + "This endpoint evaluates operating periods from the in-memory tachograph file-session model.", + "BREAK_REST intervals are excluded from periodization but still block synthetic UNKNOWN gaps.", + "Synthetic UNKNOWN intervals are created only for uncovered gaps between non-rest activities.", + "Vehicle usage is normalized before activity processing and merged across touching intervals with the same vehicle identity." + ); + } + + private OffsetDateTime utc(OffsetDateTime value) { + return value == null ? null : value.withOffsetSameInstant(java.time.ZoneOffset.UTC); + } + + private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isAfter(right) ? left : right; + } + + private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isBefore(right) ? left : right; + } + + private record PeriodizationResult( + List periodizedIntervals, + List closedPeriods + ) { + } + + private record ClosedOperatingPeriod( + long operatingPeriodNo, + OffsetDateTime startedAt, + OffsetDateTime endedAt, + long durationSeconds, + String closedBy + ) { + } +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParser.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParser.java index 882dbae..4757f8a 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParser.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParser.java @@ -30,7 +30,8 @@ public class TachographXmlParser { public ParsedTachographXml parse(String xmlContent) { try { - validate(xmlContent); + String normalizedXmlContent = normalizeXmlContent(xmlContent); + validate(normalizedXmlContent); DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(false); factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); @@ -42,7 +43,7 @@ public class TachographXmlParser { factory.setXIncludeAware(false); factory.setExpandEntityReferences(false); DocumentBuilder builder = factory.newDocumentBuilder(); - Document document = builder.parse(new InputSource(new StringReader(xmlContent))); + Document document = builder.parse(new InputSource(new StringReader(normalizedXmlContent))); String rootName = document.getDocumentElement() == null ? null : document.getDocumentElement().getNodeName(); if (!"DriverCard".equals(rootName) && !"VehicleUnit".equals(rootName)) { throw new UnsupportedTachographFileTypeException("Only DriverCard and VehicleUnit XML documents are supported."); @@ -61,6 +62,13 @@ public class TachographXmlParser { } } + private String normalizeXmlContent(String xmlContent) { + if (xmlContent == null || xmlContent.isEmpty()) { + return xmlContent; + } + return xmlContent.charAt(0) == '\uFEFF' ? xmlContent.substring(1) : xmlContent; + } + private Schema loadSchema() { try { SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); 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 d86f2dc..16c84db 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java @@ -25,10 +25,6 @@ import java.util.List; import java.util.Map; import java.util.TreeSet; import java.util.UUID; -import javax.xml.xpath.XPath; -import javax.xml.xpath.XPathConstants; -import javax.xml.xpath.XPathExpressionException; -import javax.xml.xpath.XPathFactory; import org.springframework.stereotype.Component; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -39,6 +35,7 @@ public class VehicleUnitXmlExtractionService { private final DriverKeyFactory driverKeyFactory; private final VehicleKeyFactory vehicleKeyFactory; + private final XmlExpressionEvaluator xml = new XmlExpressionEvaluator(); public VehicleUnitXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) { this.driverKeyFactory = driverKeyFactory; @@ -688,22 +685,11 @@ public class VehicleUnitXmlExtractionService { } private NodeList nodes(Object node, String expression) { - try { - XPath xpath = XPathFactory.newInstance().newXPath(); - return (NodeList) xpath.evaluate(expression, node, XPathConstants.NODESET); - } catch (XPathExpressionException e) { - throw new IllegalStateException("Invalid XPath expression: " + expression, e); - } + return xml.nodes(node, expression); } private String text(Object node, String expression) { - try { - XPath xpath = XPathFactory.newInstance().newXPath(); - String value = xpath.evaluate(expression, node); - return value == null || value.isBlank() ? null : value.trim(); - } catch (XPathExpressionException e) { - throw new IllegalStateException("Invalid XPath expression: " + expression, e); - } + return xml.text(node, expression); } private OffsetDateTime offsetDateTime(String value) { diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/XmlExpressionEvaluator.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/XmlExpressionEvaluator.java new file mode 100644 index 0000000..7c1e02b --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/XmlExpressionEvaluator.java @@ -0,0 +1,49 @@ +package at.procon.eventhub.tachographfilesession.service; + +import java.util.HashMap; +import java.util.Map; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpression; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import org.w3c.dom.NodeList; + +final class XmlExpressionEvaluator { + + private static final XPathFactory XPATH_FACTORY = XPathFactory.newInstance(); + + private final ThreadLocal contexts = ThreadLocal.withInitial(XPathContext::new); + + NodeList nodes(Object node, String expression) { + try { + return (NodeList) contexts.get().compile(expression).evaluate(node, XPathConstants.NODESET); + } catch (XPathExpressionException e) { + throw new IllegalStateException("Invalid XPath expression: " + expression, e); + } + } + + String text(Object node, String expression) { + try { + String value = contexts.get().compile(expression).evaluate(node); + return value == null || value.isBlank() ? null : value.trim(); + } catch (XPathExpressionException e) { + throw new IllegalStateException("Invalid XPath expression: " + expression, e); + } + } + + private static final class XPathContext { + private final XPath xpath = XPATH_FACTORY.newXPath(); + private final Map compiledExpressions = new HashMap<>(); + + private XPathExpression compile(String expression) { + return compiledExpressions.computeIfAbsent(expression, key -> { + try { + return xpath.compile(key); + } catch (XPathExpressionException e) { + throw new IllegalStateException("Invalid XPath expression: " + key, e); + } + }); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0e018ed..04f9d27 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -125,6 +125,11 @@ eventhub: ttl: 4h max-sessions: 100 max-file-size-bytes: 20971520 + processing: + operating-split-idle-hours: 7 + significant-driving-minutes: 3 + merge-gap-seconds: 0 + gap-detection-tolerance-seconds: 0 legal-requirements: base-url: ${LEGAL_REQUIREMENTS_BASE_URL:https://legalrequirements.services.bytebar.eu/ODataV4/LR} username: ${LEGAL_REQUIREMENTS_USERNAME:} diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionControllerTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionControllerTest.java index df5422e..41b4465 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionControllerTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionControllerTest.java @@ -10,11 +10,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto; +import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto; +import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest; import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto; import at.procon.eventhub.tachographfilesession.model.ExtractionStats; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService; import java.time.Instant; import java.util.List; @@ -23,13 +26,15 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; class TachographFileSessionControllerTest { @Test void uploadsSessionListsDriversAndDeletes() throws Exception { TachographFileSessionService service = org.mockito.Mockito.mock(TachographFileSessionService.class); - MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TachographFileSessionController(service)) + TachographFileSessionProcessingService processingService = org.mockito.Mockito.mock(TachographFileSessionProcessingService.class); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TachographFileSessionController(service, processingService)) .setControllerAdvice(new TachographFileSessionExceptionHandler()) .build(); UUID sessionId = UUID.randomUUID(); @@ -53,6 +58,30 @@ class TachographFileSessionControllerTest { when(service.getSession(sessionId)).thenReturn(summary); when(service.listDrivers(sessionId)).thenReturn(new TachographFileSessionListDriversResponse(sessionId, List.of(driver))); when(service.getDriver(sessionId, "12:123")).thenReturn(new TachographFileDriverDetailDto(sessionId, "12:123", null, null, List.of(), List.of(), List.of(), List.of(), List.of(), List.of())); + when(processingService.evaluateOperatingPeriods(eq(sessionId), eq("12:123"), org.mockito.ArgumentMatchers.any(TachographOperatingPeriodsProcessingRequest.class))) + .thenReturn(new TachographOperatingPeriodsProcessingResultDto( + sessionId, + "12:123", + Instant.parse("2026-05-12T08:00:00Z").atOffset(java.time.ZoneOffset.UTC), + Instant.parse("2026-05-12T12:00:00Z").atOffset(java.time.ZoneOffset.UTC), + Instant.parse("2026-05-12T08:00:00Z").atOffset(java.time.ZoneOffset.UTC), + Instant.parse("2026-05-12T12:00:00Z").atOffset(java.time.ZoneOffset.UTC), + 3, + 2, + 2, + 2, + 1, + 7, + 3, + 0, + 0, + null, + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + )); when(service.deleteSession(sessionId)).thenReturn(new TachographFileSessionDeleteResponse(sessionId, true)); mockMvc.perform(multipart("/api/eventhub/tachograph-file-sessions") @@ -75,6 +104,17 @@ class TachographFileSessionControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.driverKey").value("12:123")); + mockMvc.perform(post("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}/processing/operating-periods", sessionId, "12:123") + .contentType("application/json") + .content(""" + { + "significantDrivingMinutes": 5 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.driverKey").value("12:123")) + .andExpect(jsonPath("$.operatingPeriodCount").value(1)); + mockMvc.perform(delete("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.deleted").value(true)); diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java new file mode 100644 index 0000000..82449f1 --- /dev/null +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java @@ -0,0 +1,111 @@ +package at.procon.eventhub.tachographfilesession.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractionWarning; +import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent; +import at.procon.eventhub.tachographfilesession.model.ExtractionStats; +import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class DriverTimelineBuilderTest { + + private final DriverTimelineBuilder builder = new DriverTimelineBuilder(); + + @Test + void mergesTouchingVehicleUsageIntervalsAndNormalizesUnknownActivity() { + DriverExtractionSession driver = new DriverExtractionSession( + "12:123", + null, + null, + List.of(), + List.of(), + List.of( + new ExtractedCardVehicleUsageInterval( + "CVU-1", + OffsetDateTime.parse("2026-05-01T00:00:00Z"), + OffsetDateTime.parse("2026-05-01T23:59:59Z"), + 100L, + 200L, + "12:REG-1", + "VIN-1", + "a" + ), + new ExtractedCardVehicleUsageInterval( + "CVU-2", + OffsetDateTime.parse("2026-05-02T00:00:00Z"), + OffsetDateTime.parse("2026-05-02T08:00:00Z"), + 201L, + 250L, + "12:REG-1", + "VIN-1", + "b" + ) + ), + List.of( + new ExtractedCardActivityInterval( + "ACT-1", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T09:00:00Z"), + "UNKNOWN_ACTIVITY", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-1", + "VIN-1", + "c" + ) + ), + List.of(new ExtractedSupportEvent( + "SUP-1", + OffsetDateTime.parse("2026-05-01T08:30:00Z"), + "PLACE", + "BEGIN_DAILY_WORK_PERIOD", + "DRIVER", + "12:REG-1", + "VIN-1", + null, + null, + null, + null, + null, + null, + null, + "d" + )), + List.of(new ExtractionWarning("W1", "warning", "/x")) + ); + TachographFileSession session = new TachographFileSession( + UUID.randomUUID(), + new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null), + Map.of(driver.driverKey(), driver), + new ExtractionStats(1, 1, 2, 1, 1, 1), + List.of(new ExtractionWarning("W2", "session warning", "/y")), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + + ResolvedDriverTimeline timeline = builder.build(session, driver); + + assertThat(timeline.sourceKind()).isEqualTo("DRIVER_CARD"); + assertThat(timeline.vehicleUsageIntervals()).hasSize(1); + assertThat(timeline.vehicleUsageIntervals().get(0).from()).isEqualTo(OffsetDateTime.parse("2026-05-01T00:00:00Z")); + assertThat(timeline.vehicleUsageIntervals().get(0).to()).isEqualTo(OffsetDateTime.parse("2026-05-02T08:00:00Z")); + assertThat(timeline.activityIntervals()).hasSize(1); + assertThat(timeline.activityIntervals().get(0).activityType()).isEqualTo("UNKNOWN"); + assertThat(timeline.loadedFrom()).isEqualTo(OffsetDateTime.parse("2026-05-01T00:00:00Z")); + assertThat(timeline.loadedTo()).isEqualTo(OffsetDateTime.parse("2026-05-02T08:00:00Z")); + assertThat(timeline.warnings()).extracting(ExtractionWarning::code).containsExactly("W2", "W1"); + } +} diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingServiceTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingServiceTest.java new file mode 100644 index 0000000..08b6055 --- /dev/null +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingServiceTest.java @@ -0,0 +1,99 @@ +package at.procon.eventhub.tachographfilesession.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest; +import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto; +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractionStats; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class TachographFileSessionProcessingServiceTest { + + @Test + void evaluatesOperatingPeriodsFromSessionTimeline() { + EventHubProperties properties = new EventHubProperties(); + TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); + TachographFileSessionProcessingService service = new TachographFileSessionProcessingService( + repository, + new DriverTimelineBuilder(), + properties + ); + + DriverExtractionSession driver = new DriverExtractionSession( + "12:123", + null, + null, + List.of(), + List.of(), + List.of(new ExtractedCardVehicleUsageInterval( + "CVU-1", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T20:00:00Z"), + 100L, + 200L, + "12:REG-1", + "VIN-1", + "vu" + )), + List.of( + new ExtractedCardActivityInterval("ACT-1", OffsetDateTime.parse("2026-05-01T08:00:00Z"), OffsetDateTime.parse("2026-05-01T08:30:00Z"), "BREAK_REST", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "a"), + new ExtractedCardActivityInterval("ACT-2", OffsetDateTime.parse("2026-05-01T08:30:00Z"), OffsetDateTime.parse("2026-05-01T09:00:00Z"), "WORK", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "b"), + new ExtractedCardActivityInterval("ACT-3", OffsetDateTime.parse("2026-05-01T09:00:00Z"), OffsetDateTime.parse("2026-05-01T09:20:00Z"), "DRIVE", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "c"), + new ExtractedCardActivityInterval("ACT-4", OffsetDateTime.parse("2026-05-01T09:20:00Z"), OffsetDateTime.parse("2026-05-01T10:00:00Z"), "BREAK_REST", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "d"), + new ExtractedCardActivityInterval("ACT-5", OffsetDateTime.parse("2026-05-01T10:00:00Z"), OffsetDateTime.parse("2026-05-01T10:15:00Z"), "WORK", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "e"), + new ExtractedCardActivityInterval("ACT-6", OffsetDateTime.parse("2026-05-01T10:15:00Z"), OffsetDateTime.parse("2026-05-01T10:35:00Z"), "DRIVE", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "f"), + new ExtractedCardActivityInterval("ACT-7", OffsetDateTime.parse("2026-05-01T18:45:00Z"), OffsetDateTime.parse("2026-05-01T19:00:00Z"), "WORK", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "g") + ), + List.of(), + List.of() + ); + TachographFileSession session = new TachographFileSession( + UUID.randomUUID(), + new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null), + Map.of(driver.driverKey(), driver), + new ExtractionStats(1, 7, 1, 1, 1, 0), + List.of(), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + repository.save(session); + + TachographOperatingPeriodsProcessingResultDto result = service.evaluateOperatingPeriods( + session.sessionId(), + driver.driverKey(), + new TachographOperatingPeriodsProcessingRequest( + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T19:00:00Z"), + 7, + 10, + 0, + 0 + ) + ); + + assertThat(result.timeline().sourceKind()).isEqualTo("DRIVER_CARD"); + assertThat(result.evaluationIntervals()).extracting("activityType").contains("WORK", "DRIVE"); + assertThat(result.operatingPeriods()).hasSize(2); + assertThat(result.operatingPeriods().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:30:00Z")); + assertThat(result.operatingPeriods().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:35:00Z")); + assertThat(result.operatingPeriods().get(0).closedBy()).isEqualTo("UNKNOWN_GAP"); + assertThat(result.operatingPeriods().get(0).drivingTimeInterruptionEvaluation().departureAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T09:00:00Z")); + assertThat(result.operatingPeriods().get(0).drivingTimeInterruptionEvaluation().interruptionsBetweenSignificantDrivingPeriods()).hasSize(1); + assertThat(result.operatingPeriods().get(0).drivingTimeInterruptionEvaluation().interruptionsBetweenSignificantDrivingPeriods().get(0).from()) + .isEqualTo(OffsetDateTime.parse("2026-05-01T09:20:00Z")); + assertThat(result.operatingPeriods().get(1).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T18:45:00Z")); + assertThat(result.operatingPeriods().get(1).closedBy()).isEqualTo("FLUSH"); + } +} diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParserTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParserTest.java index 839197d..1d63ed1 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParserTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParserTest.java @@ -25,6 +25,16 @@ class TachographXmlParserTest { assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("VehicleUnit"); } + @Test + void parsesXmlWithLeadingUtf8BomCharacter() { + String xmlWithBom = "\uFEFF" + DriverCardXmlSamples.validDriverCardXml(); + + TachographXmlParser.ParsedTachographXml parsed = parser.parse(xmlWithBom); + + assertThat(parsed.rootElementName()).isEqualTo("DriverCard"); + assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("DriverCard"); + } + @Test void rejectsInvalidXmlAgainstSchema() { String invalid = "";