Add tachograph file session operating-period processing

This commit is contained in:
trifonovt 2026-05-12 17:05:45 +02:00
parent 4ad6fd7dac
commit a20d4c241e
24 changed files with 1718 additions and 65 deletions

View File

@ -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", "name": "Delete tachograph file session",
"request": { "request": {

View File

@ -318,6 +318,7 @@ public class EventHubProperties {
private int maxSessions = 100; private int maxSessions = 100;
private long maxFileSizeBytes = 20L * 1024L * 1024L; private long maxFileSizeBytes = 20L * 1024L * 1024L;
private final LegalRequirements legalRequirements = new LegalRequirements(); private final LegalRequirements legalRequirements = new LegalRequirements();
private final Processing processing = new Processing();
public Duration getTtl() { public Duration getTtl() {
return ttl; return ttl;
@ -348,6 +349,49 @@ public class EventHubProperties {
public LegalRequirements getLegalRequirements() { public LegalRequirements getLegalRequirements() {
return legalRequirements; 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 { public static class LegalRequirements {

View File

@ -2,9 +2,12 @@ package at.procon.eventhub.tachographfilesession.api;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse; import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto; 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.TachographFileSessionDeleteResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus; 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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@RestController @RestController
@ -23,9 +27,14 @@ import org.springframework.web.multipart.MultipartFile;
public class TachographFileSessionController { public class TachographFileSessionController {
private final TachographFileSessionService service; private final TachographFileSessionService service;
private final TachographFileSessionProcessingService processingService;
public TachographFileSessionController(TachographFileSessionService service) { public TachographFileSessionController(
TachographFileSessionService service,
TachographFileSessionProcessingService processingService
) {
this.service = service; this.service = service;
this.processingService = processingService;
} }
@PostMapping @PostMapping
@ -57,6 +66,15 @@ public class TachographFileSessionController {
return ResponseEntity.ok(service.getDriver(sessionId, driverKey)); return ResponseEntity.ok(service.getDriver(sessionId, driverKey));
} }
@PostMapping("/{sessionId}/drivers/{driverKey}/processing/operating-periods")
public ResponseEntity<TachographOperatingPeriodsProcessingResultDto> evaluateOperatingPeriods(
@PathVariable UUID sessionId,
@PathVariable String driverKey,
@RequestBody(required = false) TachographOperatingPeriodsProcessingRequest request
) {
return ResponseEntity.ok(processingService.evaluateOperatingPeriods(sessionId, driverKey, request));
}
@DeleteMapping("/{sessionId}") @DeleteMapping("/{sessionId}")
public ResponseEntity<TachographFileSessionDeleteResponse> deleteSession(@PathVariable UUID sessionId) { public ResponseEntity<TachographFileSessionDeleteResponse> deleteSession(@PathVariable UUID sessionId) {
return ResponseEntity.ok(service.deleteSession(sessionId)); return ResponseEntity.ok(service.deleteSession(sessionId));

View File

@ -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);
}
}

View File

@ -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<ResolvedActivityInterval> evaluationIntervals,
List<PeriodizedDriverActivityInterval> periodizedIntervals,
List<PeriodizedDriverActivityInterval> mergedIntervals,
List<ProcessedOperatingPeriod> operatingPeriods,
List<String> notes
) {
}

View File

@ -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<String> 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<String> 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
);
}
}

View File

@ -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
) {
}

View File

@ -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<ResolvedActivityInterval> rawActivities,
long breakRestSeconds,
long drivingSeconds,
long workSeconds,
long availabilitySeconds,
long unknownSeconds,
int intervalCount,
ProcessedShiftDrivingEvaluation drivingTimeInterruptionEvaluation,
boolean clippedToRequestedPeriod
) {
}

View File

@ -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<ProcessedDrivingInterruption> interruptionsBetweenSignificantDrivingPeriods
) {
}

View File

@ -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<String> 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<String> 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<String> 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"
);
}
}

View File

@ -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<ResolvedVehicleUsageInterval> vehicleUsageIntervals,
List<ResolvedActivityInterval> activityIntervals,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
}

View File

@ -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<String> sourceIntervalIds
) {
public static ResolvedVehicleUsageInterval resolved(
String intervalId,
OffsetDateTime from,
OffsetDateTime to,
Long odometerBeginKm,
Long odometerEndKm,
String registrationKey,
String vehicleKey,
String sourceKind,
List<String> sourceIntervalIds
) {
return new ResolvedVehicleUsageInterval(
intervalId,
from,
to,
Duration.between(from, to).getSeconds(),
odometerBeginKm,
odometerEndKm,
registrationKey,
vehicleKey,
sourceKind,
sourceIntervalIds == null ? List.of() : List.copyOf(sourceIntervalIds)
);
}
}

View File

@ -19,21 +19,13 @@ import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
import java.util.UUID; 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.springframework.stereotype.Component;
import org.w3c.dom.Document; import org.w3c.dom.Document;
import org.w3c.dom.Element; import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList; import org.w3c.dom.NodeList;
@Component @Component
@ -41,6 +33,7 @@ public class DriverCardXmlExtractionService {
private final DriverKeyFactory driverKeyFactory; private final DriverKeyFactory driverKeyFactory;
private final VehicleKeyFactory vehicleKeyFactory; private final VehicleKeyFactory vehicleKeyFactory;
private final XmlExpressionEvaluator xml = new XmlExpressionEvaluator();
public DriverCardXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) { public DriverCardXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
this.driverKeyFactory = driverKeyFactory; this.driverKeyFactory = driverKeyFactory;
@ -276,41 +269,63 @@ public class DriverCardXmlExtractionService {
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals
) { ) {
List<ExtractedCardActivityInterval> result = new ArrayList<>(activityIntervals.size()); List<ExtractedCardActivityInterval> result = new ArrayList<>(activityIntervals.size());
int usageStartIndex = 0;
for (ExtractedCardActivityInterval interval : activityIntervals) { 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; return result;
} }
private List<ExtractedCardActivityInterval> splitByVehicleCoverage( private List<ExtractedCardActivityInterval> splitByVehicleCoverage(
ExtractedCardActivityInterval interval, ExtractedCardActivityInterval interval,
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals List<ExtractedCardVehicleUsageInterval> overlappingUsages
) { ) {
Set<OffsetDateTime> cutPoints = new TreeSet<>(); TreeSet<OffsetDateTime> cutPoints = new TreeSet<>();
cutPoints.add(interval.from()); cutPoints.add(interval.from());
cutPoints.add(interval.to()); cutPoints.add(interval.to());
for (ExtractedCardVehicleUsageInterval usage : vehicleUsageIntervals) { for (ExtractedCardVehicleUsageInterval usage : overlappingUsages) {
if (!usage.to().isBefore(interval.from()) && !usage.from().isAfter(interval.to())) { if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) {
if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) { cutPoints.add(usage.from());
cutPoints.add(usage.from()); }
} OffsetDateTime usageEndExclusive = endExclusive(usage.to());
OffsetDateTime usageEndExclusive = usage.to().plusSeconds(1); if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) {
if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) { cutPoints.add(usageEndExclusive);
cutPoints.add(usageEndExclusive);
}
} }
} }
List<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints); List<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints);
List<ExtractedCardActivityInterval> segments = new ArrayList<>(Math.max(1, orderedCutPoints.size() - 1)); List<ExtractedCardActivityInterval> segments = new ArrayList<>(Math.max(1, orderedCutPoints.size() - 1));
int coverageIndex = 0;
for (int i = 0; i < orderedCutPoints.size() - 1; i++) { for (int i = 0; i < orderedCutPoints.size() - 1; i++) {
OffsetDateTime segmentFrom = orderedCutPoints.get(i); OffsetDateTime segmentFrom = orderedCutPoints.get(i);
OffsetDateTime segmentTo = orderedCutPoints.get(i + 1); OffsetDateTime segmentTo = orderedCutPoints.get(i + 1);
if (!segmentFrom.isBefore(segmentTo)) { if (!segmentFrom.isBefore(segmentTo)) {
continue; 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); String intervalId = orderedCutPoints.size() == 2 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1);
segments.add(new ExtractedCardActivityInterval( segments.add(new ExtractedCardActivityInterval(
intervalId, intervalId,
@ -328,14 +343,12 @@ public class DriverCardXmlExtractionService {
return segments; return segments;
} }
private ExtractedCardVehicleUsageInterval findVehicleCoverage( private boolean covers(ExtractedCardVehicleUsageInterval usage, OffsetDateTime timestamp) {
OffsetDateTime timestamp, return !usage.from().isAfter(timestamp) && timestamp.isBefore(endExclusive(usage.to()));
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals }
) {
return vehicleUsageIntervals.stream() private OffsetDateTime endExclusive(OffsetDateTime timestamp) {
.filter(usage -> !usage.from().isAfter(timestamp) && timestamp.isBefore(usage.to().plusSeconds(1))) return timestamp.plusSeconds(1);
.findFirst()
.orElse(null);
} }
private Element firstElement(Object node, String expression) { private Element firstElement(Object node, String expression) {
@ -347,22 +360,11 @@ public class DriverCardXmlExtractionService {
} }
private NodeList nodes(Object node, String expression) { private NodeList nodes(Object node, String expression) {
try { return xml.nodes(node, expression);
XPath xpath = XPathFactory.newInstance().newXPath();
return (NodeList) xpath.evaluate(expression, node, XPathConstants.NODESET);
} catch (XPathExpressionException e) {
throw new IllegalStateException("Invalid XPath expression: " + expression, e);
}
} }
private String text(Object node, String expression) { private String text(Object node, String expression) {
try { return xml.text(node, expression);
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);
}
} }
private OffsetDateTime offsetDateTime(String value) { private OffsetDateTime offsetDateTime(String value) {

View File

@ -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<ResolvedVehicleUsageInterval> vehicleUsageIntervals = mergeVehicleUsageIntervals(driverSession.cardVehicleUsageIntervals(), sourceKind);
List<ResolvedActivityInterval> activityIntervals = resolveActivities(driverSession.cardActivityIntervals(), sourceKind);
List<ExtractedSupportEvent> 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<ExtractionWarning> warnings = mergeWarnings(session.warnings(), driverSession.warnings());
return new ResolvedDriverTimeline(
sourceKind,
loadedFrom,
loadedTo,
vehicleUsageIntervals,
activityIntervals,
supportEvents,
warnings
);
}
private List<ResolvedVehicleUsageInterval> mergeVehicleUsageIntervals(
List<ExtractedCardVehicleUsageInterval> rawIntervals,
String sourceKind
) {
if (rawIntervals == null || rawIntervals.isEmpty()) {
return List.of();
}
List<ResolvedVehicleUsageInterval> 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<ResolvedVehicleUsageInterval> result = new ArrayList<>();
ResolvedVehicleUsageInterval current = sorted.get(0);
List<String> 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<ResolvedActivityInterval> resolveActivities(
List<ExtractedCardActivityInterval> 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<ResolvedVehicleUsageInterval> vehicleUsageIntervals,
List<ResolvedActivityInterval> activityIntervals,
List<ExtractedSupportEvent> 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<ResolvedVehicleUsageInterval> vehicleUsageIntervals,
List<ResolvedActivityInterval> activityIntervals,
List<ExtractedSupportEvent> 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<ExtractionWarning> mergeWarnings(List<ExtractionWarning> sessionWarnings, List<ExtractionWarning> driverWarnings) {
LinkedHashSet<ExtractionWarning> 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;
}
}

View File

@ -60,9 +60,9 @@ public class LegalRequirementsClient {
throw new LegalRequirementsUploadException("LegalRequirements upload failed with status " + response.statusCode()); throw new LegalRequirementsUploadException("LegalRequirements upload failed with status " + response.statusCode());
} }
JsonNode root = objectMapper.readTree(response.body()); JsonNode root = objectMapper.readTree(response.body());
String dataPackageId = text(root, "DataPackageID"); String dataPackageId = text(root, "ID");
if (dataPackageId == null) { if (dataPackageId == null) {
dataPackageId = text(root, "DataPackageId"); dataPackageId = text(root, "Id");
} }
if (dataPackageId == null && root.has("value") && root.get("value").isObject()) { if (dataPackageId == null && root.has("value") && root.get("value").isObject()) {
dataPackageId = text(root.get("value"), "DataPackageID"); dataPackageId = text(root.get("value"), "DataPackageID");

View File

@ -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<ResolvedActivityInterval> knownLoadedIntervals = sortedPositiveIntervals(timeline.activityIntervals());
List<ResolvedActivityInterval> evaluationLoadedIntervals = synthesizeUnknownGaps(knownLoadedIntervals, gapDetectionTolerance);
PeriodizationResult periodization = periodize(evaluationLoadedIntervals, operatingSplitIdleThreshold);
List<PeriodizedDriverActivityInterval> mergedLoadedIntervals = mergeConsecutiveActivities(
periodization.periodizedIntervals(),
mergeGapTolerance
);
List<ResolvedActivityInterval> evaluationIntervals = clipResolvedIntervals(evaluationLoadedIntervals, requestedFrom, requestedTo);
List<PeriodizedDriverActivityInterval> periodizedIntervals = clipPeriodizedIntervals(periodization.periodizedIntervals(), requestedFrom, requestedTo);
List<PeriodizedDriverActivityInterval> mergedIntervals = clipPeriodizedIntervals(mergedLoadedIntervals, requestedFrom, requestedTo);
List<ProcessedOperatingPeriod> 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<ResolvedActivityInterval> synthesizeUnknownGaps(
List<ResolvedActivityInterval> knownIntervals,
Duration gapDetectionTolerance
) {
List<ResolvedActivityInterval> allKnown = sortedPositiveIntervals(knownIntervals);
List<ResolvedActivityInterval> nonRestActivities = allKnown.stream()
.filter(interval -> !"BREAK_REST".equals(interval.activityType()))
.toList();
if (nonRestActivities.isEmpty()) {
return List.of();
}
List<ResolvedActivityInterval> 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<ResolvedActivityInterval> 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<ResolvedActivityInterval> intervals,
Duration operatingSplitIdleThreshold
) {
List<ResolvedActivityInterval> sorted = sortedPositiveIntervals(intervals);
if (sorted.isEmpty()) {
return new PeriodizationResult(List.of(), List.of());
}
List<PeriodizedDriverActivityInterval> periodizedIntervals = new ArrayList<>();
List<ClosedOperatingPeriod> 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<PeriodizedDriverActivityInterval> mergeConsecutiveActivities(
List<PeriodizedDriverActivityInterval> intervals,
Duration mergeGapTolerance
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
List<PeriodizedDriverActivityInterval> 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<PeriodizedDriverActivityInterval> result = new ArrayList<>();
PeriodizedDriverActivityInterval current = null;
List<String> 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<ProcessedOperatingPeriod> buildOperatingPeriods(
List<ClosedOperatingPeriod> closedPeriods,
List<PeriodizedDriverActivityInterval> clippedPeriodizedIntervals,
List<ResolvedActivityInterval> knownLoadedIntervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo,
Duration mergeGapTolerance,
Duration significantDrivingThreshold
) {
if (requestedFrom == null || requestedTo == null) {
return List.of();
}
LinkedHashMap<Long, List<PeriodizedDriverActivityInterval>> intervalsByPeriod = new LinkedHashMap<>();
for (PeriodizedDriverActivityInterval interval : clippedPeriodizedIntervals) {
intervalsByPeriod.computeIfAbsent(interval.operatingPeriodNo(), ignored -> new ArrayList<>()).add(interval);
}
List<ProcessedOperatingPeriod> 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<PeriodizedDriverActivityInterval> intervals = intervalsByPeriod.getOrDefault(closedPeriod.operatingPeriodNo(), List.of());
List<ResolvedActivityInterval> 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<ResolvedActivityInterval> rawActivities,
Duration mergeGapTolerance,
Duration significantDrivingThreshold
) {
if (rawActivities.isEmpty()) {
return new ProcessedShiftDrivingEvaluation(
(int) significantDrivingThreshold.toMinutes(),
null,
null,
null,
null,
List.of()
);
}
List<ResolvedActivityInterval> mergedActivities = mergeConsecutiveRawActivities(rawActivities, mergeGapTolerance);
List<ResolvedActivityInterval> 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<ProcessedDrivingInterruption> 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<ResolvedActivityInterval> mergeConsecutiveRawActivities(
List<ResolvedActivityInterval> intervals,
Duration mergeGapTolerance
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
List<ResolvedActivityInterval> 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<ResolvedActivityInterval> result = new ArrayList<>();
ResolvedActivityInterval current = null;
List<String> 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<PeriodizedDriverActivityInterval> intervals, String activityType) {
return intervals.stream()
.filter(interval -> activityType.equals(interval.activityType()))
.mapToLong(PeriodizedDriverActivityInterval::durationSeconds)
.sum();
}
private List<ResolvedActivityInterval> clipResolvedIntervals(
List<ResolvedActivityInterval> 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<PeriodizedDriverActivityInterval> clipPeriodizedIntervals(
List<PeriodizedDriverActivityInterval> 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<ResolvedActivityInterval> subtractCoverage(
ResolvedActivityInterval candidate,
List<ResolvedActivityInterval> coverage
) {
List<ResolvedActivityInterval> 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<ResolvedActivityInterval> sortedPositiveIntervals(List<ResolvedActivityInterval> 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<String> 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<PeriodizedDriverActivityInterval> periodizedIntervals,
List<ClosedOperatingPeriod> closedPeriods
) {
}
private record ClosedOperatingPeriod(
long operatingPeriodNo,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
long durationSeconds,
String closedBy
) {
}
}

View File

@ -30,7 +30,8 @@ public class TachographXmlParser {
public ParsedTachographXml parse(String xmlContent) { public ParsedTachographXml parse(String xmlContent) {
try { try {
validate(xmlContent); String normalizedXmlContent = normalizeXmlContent(xmlContent);
validate(normalizedXmlContent);
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false); factory.setNamespaceAware(false);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
@ -42,7 +43,7 @@ public class TachographXmlParser {
factory.setXIncludeAware(false); factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false); factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder(); 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(); String rootName = document.getDocumentElement() == null ? null : document.getDocumentElement().getNodeName();
if (!"DriverCard".equals(rootName) && !"VehicleUnit".equals(rootName)) { if (!"DriverCard".equals(rootName) && !"VehicleUnit".equals(rootName)) {
throw new UnsupportedTachographFileTypeException("Only DriverCard and VehicleUnit XML documents are supported."); 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() { private Schema loadSchema() {
try { try {
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);

View File

@ -25,10 +25,6 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TreeSet; import java.util.TreeSet;
import java.util.UUID; 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.springframework.stereotype.Component;
import org.w3c.dom.Document; import org.w3c.dom.Document;
import org.w3c.dom.Element; import org.w3c.dom.Element;
@ -39,6 +35,7 @@ public class VehicleUnitXmlExtractionService {
private final DriverKeyFactory driverKeyFactory; private final DriverKeyFactory driverKeyFactory;
private final VehicleKeyFactory vehicleKeyFactory; private final VehicleKeyFactory vehicleKeyFactory;
private final XmlExpressionEvaluator xml = new XmlExpressionEvaluator();
public VehicleUnitXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) { public VehicleUnitXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
this.driverKeyFactory = driverKeyFactory; this.driverKeyFactory = driverKeyFactory;
@ -688,22 +685,11 @@ public class VehicleUnitXmlExtractionService {
} }
private NodeList nodes(Object node, String expression) { private NodeList nodes(Object node, String expression) {
try { return xml.nodes(node, expression);
XPath xpath = XPathFactory.newInstance().newXPath();
return (NodeList) xpath.evaluate(expression, node, XPathConstants.NODESET);
} catch (XPathExpressionException e) {
throw new IllegalStateException("Invalid XPath expression: " + expression, e);
}
} }
private String text(Object node, String expression) { private String text(Object node, String expression) {
try { return xml.text(node, expression);
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);
}
} }
private OffsetDateTime offsetDateTime(String value) { private OffsetDateTime offsetDateTime(String value) {

View File

@ -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<XPathContext> 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<String, XPathExpression> 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);
}
});
}
}
}

View File

@ -125,6 +125,11 @@ eventhub:
ttl: 4h ttl: 4h
max-sessions: 100 max-sessions: 100
max-file-size-bytes: 20971520 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: legal-requirements:
base-url: ${LEGAL_REQUIREMENTS_BASE_URL:https://legalrequirements.services.bytebar.eu/ODataV4/LR} base-url: ${LEGAL_REQUIREMENTS_BASE_URL:https://legalrequirements.services.bytebar.eu/ODataV4/LR}
username: ${LEGAL_REQUIREMENTS_USERNAME:} username: ${LEGAL_REQUIREMENTS_USERNAME:}

View File

@ -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.CreateTachographFileSessionResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto; 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.TachographFileDriverSummaryDto;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats; import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@ -23,13 +26,15 @@ import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
class TachographFileSessionControllerTest { class TachographFileSessionControllerTest {
@Test @Test
void uploadsSessionListsDriversAndDeletes() throws Exception { void uploadsSessionListsDriversAndDeletes() throws Exception {
TachographFileSessionService service = org.mockito.Mockito.mock(TachographFileSessionService.class); 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()) .setControllerAdvice(new TachographFileSessionExceptionHandler())
.build(); .build();
UUID sessionId = UUID.randomUUID(); UUID sessionId = UUID.randomUUID();
@ -53,6 +58,30 @@ class TachographFileSessionControllerTest {
when(service.getSession(sessionId)).thenReturn(summary); when(service.getSession(sessionId)).thenReturn(summary);
when(service.listDrivers(sessionId)).thenReturn(new TachographFileSessionListDriversResponse(sessionId, List.of(driver))); 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(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)); when(service.deleteSession(sessionId)).thenReturn(new TachographFileSessionDeleteResponse(sessionId, true));
mockMvc.perform(multipart("/api/eventhub/tachograph-file-sessions") mockMvc.perform(multipart("/api/eventhub/tachograph-file-sessions")
@ -75,6 +104,17 @@ class TachographFileSessionControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.driverKey").value("12:123")); .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)) mockMvc.perform(delete("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.deleted").value(true)); .andExpect(jsonPath("$.deleted").value(true));

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -25,6 +25,16 @@ class TachographXmlParserTest {
assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("VehicleUnit"); 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 @Test
void rejectsInvalidXmlAgainstSchema() { void rejectsInvalidXmlAgainstSchema() {
String invalid = "<DriverCard><Identification></DriverCard>"; String invalid = "<DriverCard><Identification></DriverCard>";