Compare commits

...

8 Commits

Author SHA1 Message Date
trifonovt 7209a73d30 Handle tachograph activity times without offsets 2026-05-12 17:30:54 +02:00
trifonovt a20d4c241e Add tachograph file session operating-period processing 2026-05-12 17:05:45 +02:00
trifonovt 4ad6fd7dac Extract VU support events into driver sessions 2026-05-12 15:35:31 +02:00
trifonovt e30f98b2c0 Extract VU activity intervals into driver sessions 2026-05-12 15:15:57 +02:00
trifonovt 6b43a4b0e8 Add vehicle unit tachograph file sessions 2026-05-12 15:06:08 +02:00
trifonovt 39714b90b3 Harden tachograph file session extraction 2026-05-12 14:46:35 +02:00
trifonovt c85b657acf Add Postman requests for tachograph file sessions 2026-05-12 14:31:39 +02:00
trifonovt 5a1061a314 Add in-memory tachograph file session flow 2026-05-12 14:28:06 +02:00
64 changed files with 12525 additions and 0 deletions

View File

@ -201,6 +201,208 @@
]
}
}
},
{
"name": "Tachograph file sessions",
"item": [
{
"name": "Create tachograph file session",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "{{tachographDddFile}}"
},
{
"key": "tenantKey",
"type": "text",
"value": "{{tenantKey}}"
},
{
"key": "sourceInstanceKey",
"type": "text",
"value": "{{sourceInstanceKey}}"
},
{
"key": "sessionLabel",
"type": "text",
"value": "{{sessionLabel}}"
}
]
},
"url": {
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"tachograph-file-sessions"
]
}
}
},
{
"name": "Create tachograph vehicle unit file session",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "{{tachographVuDddFile}}"
},
{
"key": "tenantKey",
"type": "text",
"value": "{{tenantKey}}"
},
{
"key": "sourceInstanceKey",
"type": "text",
"value": "legalrequirements-vehicleunit"
},
{
"key": "sessionLabel",
"type": "text",
"value": "vehicle-unit upload sample"
}
]
},
"url": {
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"tachograph-file-sessions"
]
}
}
},
{
"name": "Get tachograph file session",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"tachograph-file-sessions",
"{{sessionId}}"
]
}
}
},
{
"name": "List tachograph file session drivers",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}/drivers",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"tachograph-file-sessions",
"{{sessionId}}",
"drivers"
]
}
}
},
{
"name": "Get tachograph file session driver detail",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}/drivers/{{driverKey}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"tachograph-file-sessions",
"{{sessionId}}",
"drivers",
"{{driverKey}}"
]
}
}
},
{
"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": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"tachograph-file-sessions",
"{{sessionId}}"
]
}
}
}
]
}
],
"variable": [
@ -227,6 +429,30 @@
{
"key": "occurredTo",
"value": "2026-05-01T00:00:00Z"
},
{
"key": "sourceInstanceKey",
"value": "legalrequirements-drivercard"
},
{
"key": "sessionLabel",
"value": "driver-card upload sample"
},
{
"key": "sessionId",
"value": "00000000-0000-0000-0000-000000000000"
},
{
"key": "driverKey",
"value": "12:12345678901200"
},
{
"key": "tachographDddFile",
"value": "C:\\\\temp\\\\driver-card.ddd"
},
{
"key": "tachographVuDddFile",
"value": "C:\\\\temp\\\\vehicle-unit.ddd"
}
]
}

View File

@ -30,6 +30,7 @@ public class EventHubProperties {
private final Batch batch = new Batch();
private final Tachograph tachograph = new Tachograph();
private final TachographFileSession tachographFileSession = new TachographFileSession();
private final EsperPoc esperPoc = new EsperPoc();
private final YellowFox yellowFox = new YellowFox();
@ -41,6 +42,10 @@ public class EventHubProperties {
return tachograph;
}
public TachographFileSession getTachographFileSession() {
return tachographFileSession;
}
public EsperPoc getEsperPoc() {
return esperPoc;
}
@ -308,6 +313,166 @@ public class EventHubProperties {
}
}
public static class TachographFileSession {
private Duration ttl = Duration.ofHours(4);
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;
}
public void setTtl(Duration ttl) {
if (ttl != null && !ttl.isNegative() && !ttl.isZero()) {
this.ttl = ttl;
}
}
public int getMaxSessions() {
return maxSessions;
}
public void setMaxSessions(int maxSessions) {
this.maxSessions = Math.max(1, maxSessions);
}
public long getMaxFileSizeBytes() {
return maxFileSizeBytes;
}
public void setMaxFileSizeBytes(long maxFileSizeBytes) {
this.maxFileSizeBytes = Math.max(1024L, maxFileSizeBytes);
}
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 {
private static final String DEFAULT_BASE_URL = "https://legalrequirements.services.bytebar.eu/ODataV4/LR";
private String baseUrl = DEFAULT_BASE_URL;
private String username;
private String password;
private Duration connectTimeout = Duration.ofSeconds(20);
private Duration readTimeout = Duration.ofMinutes(2);
private boolean resetSessionAfterUse = false;
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = normalizeBaseUrl(baseUrl);
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public Duration getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Duration connectTimeout) {
if (connectTimeout != null && !connectTimeout.isNegative() && !connectTimeout.isZero()) {
this.connectTimeout = connectTimeout;
}
}
public Duration getReadTimeout() {
return readTimeout;
}
public void setReadTimeout(Duration readTimeout) {
if (readTimeout != null && !readTimeout.isNegative() && !readTimeout.isZero()) {
this.readTimeout = readTimeout;
}
}
public boolean isResetSessionAfterUse() {
return resetSessionAfterUse;
}
public void setResetSessionAfterUse(boolean resetSessionAfterUse) {
this.resetSessionAfterUse = resetSessionAfterUse;
}
private String normalizeBaseUrl(String baseUrl) {
if (baseUrl == null || baseUrl.isBlank()) {
return DEFAULT_BASE_URL;
}
String normalized = baseUrl.trim();
while (normalized.startsWith(":")) {
normalized = normalized.substring(1).trim();
}
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1).trim();
}
return normalized.isBlank() ? DEFAULT_BASE_URL : normalized;
}
}
public static class TachographDataSource {
private String jdbcUrl;
private String username;

View File

@ -0,0 +1,82 @@
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;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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
@RequestMapping("/api/eventhub/tachograph-file-sessions")
public class TachographFileSessionController {
private final TachographFileSessionService service;
private final TachographFileSessionProcessingService processingService;
public TachographFileSessionController(
TachographFileSessionService service,
TachographFileSessionProcessingService processingService
) {
this.service = service;
this.processingService = processingService;
}
@PostMapping
public ResponseEntity<CreateTachographFileSessionResponse> createSession(
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String tenantKey,
@RequestParam(required = false) String sourceInstanceKey,
@RequestParam(required = false) String sessionLabel
) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(service.createSession(file, tenantKey, sourceInstanceKey, sessionLabel));
}
@GetMapping("/{sessionId}")
public ResponseEntity<TachographFileSessionSummaryDto> getSession(@PathVariable UUID sessionId) {
return ResponseEntity.ok(service.getSession(sessionId));
}
@GetMapping("/{sessionId}/drivers")
public ResponseEntity<TachographFileSessionListDriversResponse> listDrivers(@PathVariable UUID sessionId) {
return ResponseEntity.ok(service.listDrivers(sessionId));
}
@GetMapping("/{sessionId}/drivers/{driverKey}")
public ResponseEntity<TachographFileDriverDetailDto> getDriver(
@PathVariable UUID sessionId,
@PathVariable String 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}")
public ResponseEntity<TachographFileSessionDeleteResponse> deleteSession(@PathVariable UUID sessionId) {
return ResponseEntity.ok(service.deleteSession(sessionId));
}
}

View File

@ -0,0 +1,52 @@
package at.procon.eventhub.tachographfilesession.api;
import at.procon.eventhub.tachographfilesession.service.DriverNotFoundInSessionException;
import at.procon.eventhub.tachographfilesession.service.LegalRequirementsUploadException;
import at.procon.eventhub.tachographfilesession.service.LegalRequirementsXmlDownloadException;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionNotFoundException;
import at.procon.eventhub.tachographfilesession.service.TachographXmlValidationException;
import at.procon.eventhub.tachographfilesession.service.UnsupportedTachographFileTypeException;
import java.time.OffsetDateTime;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(basePackageClasses = TachographFileSessionController.class)
public class TachographFileSessionExceptionHandler {
@ExceptionHandler({
TachographFileSessionNotFoundException.class,
DriverNotFoundInSessionException.class
})
public ResponseEntity<Map<String, Object>> notFound(RuntimeException exception) {
return error(HttpStatus.NOT_FOUND, exception);
}
@ExceptionHandler({
IllegalArgumentException.class,
TachographXmlValidationException.class,
UnsupportedTachographFileTypeException.class
})
public ResponseEntity<Map<String, Object>> badRequest(RuntimeException exception) {
return error(HttpStatus.BAD_REQUEST, exception);
}
@ExceptionHandler({
LegalRequirementsUploadException.class,
LegalRequirementsXmlDownloadException.class
})
public ResponseEntity<Map<String, Object>> badGateway(RuntimeException exception) {
return error(HttpStatus.BAD_GATEWAY, exception);
}
private ResponseEntity<Map<String, Object>> error(HttpStatus status, RuntimeException exception) {
return ResponseEntity.status(status).body(Map.of(
"timestamp", OffsetDateTime.now().toString(),
"status", status.value(),
"error", status.getReasonPhrase(),
"message", exception.getMessage()
));
}
}

View File

@ -0,0 +1,6 @@
package at.procon.eventhub.tachographfilesession.dto;
public record CreateTachographFileSessionResponse(
TachographFileSessionSummaryDto session
) {
}

View File

@ -0,0 +1,26 @@
package at.procon.eventhub.tachographfilesession.dto;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriver;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration;
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import java.util.List;
import java.util.UUID;
public record TachographFileDriverDetailDto(
UUID sessionId,
String driverKey,
ExtractedDriver driver,
ExtractedDriverCard driverCard,
List<ExtractedVehicleRegistration> vehicleRegistrations,
List<ExtractedVehicle> vehicles,
List<ExtractedCardVehicleUsageInterval> cardVehicleUsageIntervals,
List<ExtractedCardActivityInterval> cardActivityIntervals,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
}

View File

@ -0,0 +1,12 @@
package at.procon.eventhub.tachographfilesession.dto;
public record TachographFileDriverSummaryDto(
String driverKey,
String surname,
String firstNames,
String cardNation,
String cardNumber,
int activityIntervalCount,
int cardVehicleUsageIntervalCount
) {
}

View File

@ -0,0 +1,9 @@
package at.procon.eventhub.tachographfilesession.dto;
import java.util.UUID;
public record TachographFileSessionDeleteResponse(
UUID sessionId,
boolean deleted
) {
}

View File

@ -0,0 +1,10 @@
package at.procon.eventhub.tachographfilesession.dto;
import java.util.List;
import java.util.UUID;
public record TachographFileSessionListDriversResponse(
UUID sessionId,
List<TachographFileDriverSummaryDto> drivers
) {
}

View File

@ -0,0 +1,23 @@
package at.procon.eventhub.tachographfilesession.dto;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record TachographFileSessionSummaryDto(
UUID sessionId,
String tenantKey,
String sourceInstanceKey,
String sessionLabel,
String originalFileName,
boolean driverCardFile,
String legalRequirementsDataPackageId,
ExtractionStats stats,
List<TachographFileDriverSummaryDto> drivers,
List<ExtractionWarning> warnings,
Instant createdAt,
Instant expiresAt
) {
}

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,16 @@
package at.procon.eventhub.tachographfilesession.model;
import java.util.List;
public record DriverExtractionSession(
String driverKey,
ExtractedDriver driver,
ExtractedDriverCard driverCard,
List<ExtractedVehicleRegistration> vehicleRegistrations,
List<ExtractedVehicle> vehicles,
List<ExtractedCardVehicleUsageInterval> cardVehicleUsageIntervals,
List<ExtractedCardActivityInterval> cardActivityIntervals,
List<ExtractedSupportEvent> supportEvents,
List<ExtractionWarning> warnings
) {
}

View File

@ -0,0 +1,17 @@
package at.procon.eventhub.tachographfilesession.model;
import java.time.OffsetDateTime;
public record ExtractedCardActivityInterval(
String intervalId,
OffsetDateTime from,
OffsetDateTime to,
String activityType,
String slot,
String cardStatus,
String drivingStatus,
String registrationKey,
String vehicleKey,
String rawRecordPath
) {
}

View File

@ -0,0 +1,15 @@
package at.procon.eventhub.tachographfilesession.model;
import java.time.OffsetDateTime;
public record ExtractedCardVehicleUsageInterval(
String intervalId,
OffsetDateTime from,
OffsetDateTime to,
Long odometerBeginKm,
Long odometerEndKm,
String registrationKey,
String vehicleKey,
String rawRecordPath
) {
}

View File

@ -0,0 +1,16 @@
package at.procon.eventhub.tachographfilesession.model;
import java.time.LocalDate;
public record ExtractedDriver(
String driverKey,
String sourceDriverId,
String surname,
String firstNames,
LocalDate birthDate,
String preferredLanguage,
String drivingLicenceNumber,
String drivingLicenceNation,
String drivingLicenceAuthority
) {
}

View File

@ -0,0 +1,14 @@
package at.procon.eventhub.tachographfilesession.model;
import java.time.OffsetDateTime;
public record ExtractedDriverCard(
String sourceDriverCardId,
String cardNation,
String cardNumber,
String issuingAuthorityName,
OffsetDateTime issueDate,
OffsetDateTime validityBegin,
OffsetDateTime expiryDate
) {
}

View File

@ -0,0 +1,23 @@
package at.procon.eventhub.tachographfilesession.model;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
public record ExtractedSupportEvent(
String eventId,
OffsetDateTime occurredAt,
String eventDomain,
String eventType,
String slot,
String registrationKey,
String vehicleKey,
String country,
String region,
BigDecimal latitude,
BigDecimal longitude,
String authenticationStatus,
Long odometerKm,
String code,
String rawRecordPath
) {
}

View File

@ -0,0 +1,8 @@
package at.procon.eventhub.tachographfilesession.model;
public record ExtractedVehicle(
String vehicleKey,
String sourceVehicleId,
String vin
) {
}

View File

@ -0,0 +1,9 @@
package at.procon.eventhub.tachographfilesession.model;
public record ExtractedVehicleRegistration(
String registrationKey,
String sourceVehicleRegistrationId,
String registrationNation,
String registrationNumber
) {
}

View File

@ -0,0 +1,11 @@
package at.procon.eventhub.tachographfilesession.model;
public record ExtractionStats(
int driverCount,
int activityIntervalCount,
int cardVehicleUsageIntervalCount,
int vehicleRegistrationCount,
int vehicleCount,
int warningCount
) {
}

View File

@ -0,0 +1,8 @@
package at.procon.eventhub.tachographfilesession.model;
public record ExtractionWarning(
String code,
String message,
String rawRecordPath
) {
}

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

@ -0,0 +1,16 @@
package at.procon.eventhub.tachographfilesession.model;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
public record TachographFileSession(
UUID sessionId,
TachographFileSessionMetadata metadata,
Map<String, DriverExtractionSession> driversByKey,
ExtractionStats extractionStats,
java.util.List<ExtractionWarning> warnings,
Instant createdAt,
Instant expiresAt
) {
}

View File

@ -0,0 +1,15 @@
package at.procon.eventhub.tachographfilesession.model;
public record TachographFileSessionMetadata(
String tenantKey,
String sourceInstanceKey,
String sessionLabel,
String originalFileName,
String uploadedFileSha256,
long uploadedFileSize,
String legalRequirementsDataPackageId,
String xmlSha256,
boolean driverCardFile,
String xmlGeneration
) {
}

View File

@ -0,0 +1,479 @@
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.ExtractedDriver;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.UUID;
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
public class DriverCardXmlExtractionService {
private final DriverKeyFactory driverKeyFactory;
private final VehicleKeyFactory vehicleKeyFactory;
public DriverCardXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
this.driverKeyFactory = driverKeyFactory;
this.vehicleKeyFactory = vehicleKeyFactory;
}
public TachographFileSession extract(
TachographXmlParser.ParsedTachographXml parsedXml,
TachographFileSessionMetadata metadata,
Instant createdAt,
Instant expiresAt
) {
Document document = parsedXml.document();
List<ExtractionWarning> warnings = new ArrayList<>();
ExtractedDriverCard driverCard = extractDriverCard(document, warnings);
if (driverCard == null || driverCard.cardNumber() == null) {
throw new TachographXmlValidationException("Driver card identification could not be extracted from XML.");
}
String driverKey = driverKeyFactory.createDriverKey(driverCard.cardNation(), driverCard.cardNumber());
driverCard = new ExtractedDriverCard(
driverKeyFactory.createSourceDriverCardId(driverKey),
driverCard.cardNation(),
driverCard.cardNumber(),
driverCard.issuingAuthorityName(),
driverCard.issueDate(),
driverCard.validityBegin(),
driverCard.expiryDate()
);
ExtractedDriver driver = extractDriver(document, driverKey, warnings);
LinkedHashMap<String, ExtractedVehicleRegistration> registrationsByKey = new LinkedHashMap<>();
LinkedHashMap<String, ExtractedVehicle> vehiclesByKey = new LinkedHashMap<>();
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals =
extractVehicleUsageIntervals(document, registrationsByKey, vehiclesByKey, warnings);
List<ExtractedCardActivityInterval> activityIntervals =
assignVehicleCoverage(extractActivityIntervals(document, warnings), vehicleUsageIntervals);
DriverExtractionSession driverSession = new DriverExtractionSession(
driverKey,
driver,
driverCard,
List.copyOf(registrationsByKey.values()),
List.copyOf(vehiclesByKey.values()),
List.copyOf(vehicleUsageIntervals),
List.copyOf(activityIntervals),
List.of(),
List.copyOf(warnings)
);
Map<String, DriverExtractionSession> driversByKey = Map.of(driverKey, driverSession);
ExtractionStats stats = new ExtractionStats(
1,
activityIntervals.size(),
vehicleUsageIntervals.size(),
registrationsByKey.size(),
vehiclesByKey.size(),
warnings.size()
);
return new TachographFileSession(
UUID.randomUUID(),
metadata,
driversByKey,
stats,
List.copyOf(warnings),
createdAt,
expiresAt
);
}
private ExtractedDriverCard extractDriverCard(Document document, List<ExtractionWarning> warnings) {
Element identification = child(document.getDocumentElement(), "Identification");
if (identification == null) {
warnings.add(new ExtractionWarning("MISSING_IDENTIFICATION", "Driver card identification block is missing.", "/DriverCard"));
return null;
}
Element cardIdentification = child(identification, "cardIdentification");
String cardNation = childText(cardIdentification, "cardIssuingMemberState");
String cardNumber = joinCardNumber(identification);
String authority = childText(child(cardIdentification, "cardIssuingAuthorityName"), "name");
return new ExtractedDriverCard(
null,
cardNation,
cardNumber,
authority,
offsetDateTime(childText(cardIdentification, "cardIssueDate")),
offsetDateTime(childText(cardIdentification, "cardValidityBegin")),
offsetDateTime(childText(cardIdentification, "cardExpiryDate"))
);
}
private ExtractedDriver extractDriver(Document document, String driverKey, List<ExtractionWarning> warnings) {
Element identification = child(document.getDocumentElement(), "Identification");
if (identification == null) {
warnings.add(new ExtractionWarning("MISSING_DRIVER", "Driver holder identification block is missing.", "/DriverCard"));
return new ExtractedDriver(
driverKey,
driverKeyFactory.createSourceDriverId(driverKey),
null,
null,
null,
null,
null,
null,
null
);
}
Element driverCardHolderIdentification = child(identification, "driverCardHolderIdentification");
Element cardHolderName = child(driverCardHolderIdentification, "cardHolderName");
Element licenceInfo = child(document.getDocumentElement(), "DrivingLicenseInfo");
Element cardDrivingLicenseInformation = child(licenceInfo, "cardDrivingLicenseInformation");
return new ExtractedDriver(
driverKey,
driverKeyFactory.createSourceDriverId(driverKey),
childText(child(cardHolderName, "holderSurname"), "name"),
childText(child(cardHolderName, "holderFirstNames"), "name"),
localDate(child(driverCardHolderIdentification, "cardHolderBirthDate")),
childText(driverCardHolderIdentification, "cardHolderPreferredLanguage"),
childText(cardDrivingLicenseInformation, "drivingLicenceNumber"),
childText(cardDrivingLicenseInformation, "drivingLicenceIssuingNation"),
childText(child(cardDrivingLicenseInformation, "drivingLicenceIssuingAuthority"), "name")
);
}
private List<ExtractedCardVehicleUsageInterval> extractVehicleUsageIntervals(
Document document,
Map<String, ExtractedVehicleRegistration> registrationsByKey,
Map<String, ExtractedVehicle> vehiclesByKey,
List<ExtractionWarning> warnings
) {
Element cardVehiclesUsed = child(child(document.getDocumentElement(), "VehiclesUsed"), "cardVehiclesUsed");
List<Element> records = children(cardVehiclesUsed, "cardVehicleRecords");
List<ExtractedCardVehicleUsageInterval> intervals = new ArrayList<>(records.size());
for (int i = 0; i < records.size(); i++) {
Element record = records.get(i);
String path = "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords[" + (i + 1) + "]";
OffsetDateTime from = offsetDateTime(childText(record, "vehicleFirstUse"));
OffsetDateTime to = offsetDateTime(childText(record, "vehicleLastUse"));
Element vehicleRegistration = child(record, "vehicleRegistration");
String registrationNation = childText(vehicleRegistration, "vehicleRegistrationNation");
String registrationNumber = childText(child(vehicleRegistration, "vehicleRegistrationNumber"), "vehicleRegNumber");
String registrationKey = vehicleKeyFactory.createRegistrationKey(registrationNation, registrationNumber);
registrationsByKey.putIfAbsent(
registrationKey,
new ExtractedVehicleRegistration(
registrationKey,
vehicleKeyFactory.createSourceVehicleRegistrationId(registrationKey),
registrationNation,
registrationNumber
)
);
String vin = childText(record, "vehicleIdentificationNumber");
String vehicleKey = vehicleKeyFactory.createVehicleKey(vin);
if (vehicleKey != null) {
vehiclesByKey.putIfAbsent(
vehicleKey,
new ExtractedVehicle(vehicleKey, vehicleKeyFactory.createSourceVehicleId(vehicleKey), vin)
);
}
if (from == null || to == null) {
warnings.add(new ExtractionWarning("INCOMPLETE_VEHICLE_USAGE", "Vehicle usage interval is missing start or end timestamp.", path));
continue;
}
intervals.add(new ExtractedCardVehicleUsageInterval(
"CVU-" + (i + 1),
from,
to,
longValue(childText(record, "vehicleOdometerBegin")),
longValue(childText(record, "vehicleOdometerEnd")),
registrationKey,
vehicleKey,
path
));
}
intervals.sort(Comparator.comparing(ExtractedCardVehicleUsageInterval::from));
return intervals;
}
private List<ExtractedCardActivityInterval> extractActivityIntervals(Document document, List<ExtractionWarning> warnings) {
Element cardDriverActivity = child(child(document.getDocumentElement(), "DriverActivityData"), "cardDriverActivity");
List<Element> dayRecords = children(cardDriverActivity, "cardActivityDailyRecord");
List<ExtractedCardActivityInterval> intervals = new ArrayList<>(dayRecords.size() * 8);
int intervalNo = 0;
for (int dayIndex = 0; dayIndex < dayRecords.size(); dayIndex++) {
Element dayRecord = dayRecords.get(dayIndex);
String dayPath = "/DriverCard/DriverActivityData/cardDriverActivity/cardActivityDailyRecord[" + (dayIndex + 1) + "]";
OffsetDateTime recordDate = offsetDateTime(childText(dayRecord, "activityRecordDate"));
if (recordDate == null) {
warnings.add(new ExtractionWarning("MISSING_ACTIVITY_RECORD_DATE", "Activity daily record has no activityRecordDate.", dayPath));
continue;
}
LocalDate date = recordDate.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate();
List<Element> changes = children(dayRecord, "activityChangeInfos");
List<ActivityChange> parsedChanges = new ArrayList<>(changes.size());
for (int changeIndex = 0; changeIndex < changes.size(); changeIndex++) {
Element change = changes.get(changeIndex);
OffsetDateTime from = combine(date, childText(change, "timeOfChange"));
if (from == null) {
warnings.add(new ExtractionWarning("INVALID_ACTIVITY_CHANGE_TIME", "Activity change has invalid timeOfChange.", dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"));
continue;
}
parsedChanges.add(new ActivityChange(
from,
normalizeActivity(childText(change, "activity")),
normalizeToken(childText(change, "slot")),
normalizeToken(childText(change, "cardStatus")),
normalizeToken(childText(change, "drivingStatus")),
dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
));
}
parsedChanges.sort(Comparator.comparing(ActivityChange::from));
for (int i = 0; i < parsedChanges.size(); i++) {
ActivityChange current = parsedChanges.get(i);
OffsetDateTime to = i + 1 < parsedChanges.size()
? parsedChanges.get(i + 1).from()
: date.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC);
if (!current.from().isBefore(to)) {
continue;
}
intervalNo++;
intervals.add(new ExtractedCardActivityInterval(
"ACT-" + intervalNo,
current.from(),
to,
current.activityType(),
current.slot(),
current.cardStatus(),
current.drivingStatus(),
null,
null,
current.rawRecordPath()
));
}
}
intervals.sort(Comparator.comparing(ExtractedCardActivityInterval::from));
return intervals;
}
private List<ExtractedCardActivityInterval> assignVehicleCoverage(
List<ExtractedCardActivityInterval> activityIntervals,
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals
) {
List<ExtractedCardActivityInterval> result = new ArrayList<>(activityIntervals.size());
int usageStartIndex = 0;
for (ExtractedCardActivityInterval interval : activityIntervals) {
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<ExtractedCardActivityInterval> splitByVehicleCoverage(
ExtractedCardActivityInterval interval,
List<ExtractedCardVehicleUsageInterval> overlappingUsages
) {
TreeSet<OffsetDateTime> cutPoints = new TreeSet<>();
cutPoints.add(interval.from());
cutPoints.add(interval.to());
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<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints);
List<ExtractedCardActivityInterval> 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;
}
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,
segmentFrom,
segmentTo,
interval.activityType(),
interval.slot(),
interval.cardStatus(),
interval.drivingStatus(),
covering == null ? null : covering.registrationKey(),
covering == null ? null : covering.vehicleKey(),
interval.rawRecordPath()
));
}
return segments;
}
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 child(Element parent, String name) {
if (parent == null) {
return null;
}
NodeList childNodes = parent.getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE && name.equals(child.getNodeName())) {
return (Element) child;
}
}
return null;
}
private List<Element> children(Element parent, String name) {
if (parent == null) {
return List.of();
}
NodeList childNodes = parent.getChildNodes();
List<Element> children = new ArrayList<>();
for (int i = 0; i < childNodes.getLength(); i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE && name.equals(child.getNodeName())) {
children.add((Element) child);
}
}
return children;
}
private String childText(Element parent, String name) {
Element child = child(parent, name);
if (child == null) {
return null;
}
String value = child.getTextContent();
return value == null || value.isBlank() ? null : value.trim();
}
private OffsetDateTime offsetDateTime(String value) {
if (value == null || value.isBlank()) {
return null;
}
return OffsetDateTime.parse(value.trim()).withOffsetSameInstant(ZoneOffset.UTC);
}
private LocalDate localDate(Element dateElement) {
if (dateElement == null) {
return null;
}
String year = childText(dateElement, "year");
String month = childText(dateElement, "month");
String day = childText(dateElement, "day");
if (year == null || month == null || day == null) {
return null;
}
return LocalDate.of(Integer.parseInt(year), Integer.parseInt(month), Integer.parseInt(day));
}
private OffsetDateTime combine(LocalDate date, String timeText) {
return TachographTimeParser.combineUtc(date, timeText);
}
private Long longValue(String value) {
if (value == null || value.isBlank()) {
return null;
}
return Long.parseLong(value.trim());
}
private String normalizeActivity(String value) {
String normalized = normalizeToken(value);
if (normalized == null) {
return "UNKNOWN_ACTIVITY";
}
return switch (normalized) {
case "DRIVING", "DRIVE" -> "DRIVE";
case "WORK" -> "WORK";
case "AVAILABILITY", "AVAILABLE" -> "AVAILABILITY";
case "BREAK_REST", "BREAK/REST", "REST" -> "BREAK_REST";
default -> "UNKNOWN_ACTIVITY";
};
}
private String normalizeToken(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim().toUpperCase().replace('-', '_').replace(' ', '_');
}
private record ActivityChange(
OffsetDateTime from,
String activityType,
String slot,
String cardStatus,
String drivingStatus,
String rawRecordPath
) {
}
private String joinCardNumber(Element identification) {
Element cardIdentification = child(identification, "cardIdentification");
Element cardNumber = child(cardIdentification, "cardNumber");
String driverIdentification = childText(cardNumber, "driverIdentification");
if (driverIdentification == null) {
return null;
}
String replacement = childText(cardNumber, "cardReplacementIndex");
String renewal = childText(cardNumber, "cardRenewalIndex");
StringBuilder builder = new StringBuilder(driverIdentification);
if (replacement != null) {
builder.append(replacement);
}
if (renewal != null) {
builder.append(renewal);
}
return builder.toString();
}
}

View File

@ -0,0 +1,28 @@
package at.procon.eventhub.tachographfilesession.service;
import org.springframework.stereotype.Component;
@Component
public class DriverKeyFactory {
public String createDriverKey(String cardNation, String cardNumber) {
String normalizedNation = normalize(cardNation, "UNKNOWN");
String normalizedCardNumber = normalize(cardNumber, "UNKNOWN");
return normalizedNation + ":" + normalizedCardNumber;
}
public String createSourceDriverId(String driverKey) {
return "DRV:" + driverKey;
}
public String createSourceDriverCardId(String driverKey) {
return "CARD:" + driverKey;
}
private String normalize(String value, String fallback) {
if (value == null || value.isBlank()) {
return fallback;
}
return value.trim();
}
}

View File

@ -0,0 +1,10 @@
package at.procon.eventhub.tachographfilesession.service;
import java.util.UUID;
public class DriverNotFoundInSessionException extends RuntimeException {
public DriverNotFoundInSessionException(UUID sessionId, String driverKey) {
super("Driver not found in session " + sessionId + ": " + driverKey);
}
}

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

@ -0,0 +1,55 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Repository;
@Repository
public class InMemoryTachographFileSessionRepository implements TachographFileSessionRepository {
private final ConcurrentHashMap<UUID, TachographFileSession> sessions = new ConcurrentHashMap<>();
private final EventHubProperties properties;
public InMemoryTachographFileSessionRepository(EventHubProperties properties) {
this.properties = properties;
}
@Override
public TachographFileSession save(TachographFileSession session) {
cleanupExpired();
if (!sessions.containsKey(session.sessionId())
&& sessions.size() >= properties.getTachographFileSession().getMaxSessions()) {
throw new IllegalStateException("Maximum number of tachograph file sessions reached.");
}
sessions.put(session.sessionId(), session);
return session;
}
@Override
public Optional<TachographFileSession> find(UUID sessionId) {
cleanupExpired();
return Optional.ofNullable(sessions.get(sessionId));
}
@Override
public boolean delete(UUID sessionId) {
cleanupExpired();
return sessions.remove(sessionId) != null;
}
@Override
public List<TachographFileSession> list() {
cleanupExpired();
return List.copyOf(sessions.values());
}
private void cleanupExpired() {
Instant now = Instant.now();
sessions.entrySet().removeIf(entry -> entry.getValue().expiresAt() != null && entry.getValue().expiresAt().isBefore(now));
}
}

View File

@ -0,0 +1,131 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.config.EventHubProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import org.springframework.stereotype.Component;
@Component
public class LegalRequirementsClient {
private final EventHubProperties properties;
private final ObjectMapper objectMapper;
public LegalRequirementsClient(EventHubProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
public LegalRequirementsUploadResult uploadTachographFile(byte[] fileBytes, String fileName) {
EventHubProperties.LegalRequirements config = properties.getTachographFileSession().getLegalRequirements();
HttpClient client = HttpClient.newBuilder()
.connectTimeout(config.getConnectTimeout())
.cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_ALL))
.build();
try {
String dataPackageId = uploadDataPackage(client, config, fileBytes, fileName);
String xml = downloadXml(client, config, dataPackageId);
return new LegalRequirementsUploadResult(dataPackageId, xml);
} finally {
if (config.isResetSessionAfterUse()) {
resetSessionQuietly(client, config);
}
}
}
private String uploadDataPackage(HttpClient client, EventHubProperties.LegalRequirements config, byte[] fileBytes, String fileName) {
try {
String payload = objectMapper.createObjectNode()
.put("FileName", fileName)
.putNull("CompressionMediaType")
.put("Data", Base64.getEncoder().encodeToString(fileBytes))
.toString();
HttpRequest request = requestBuilder(config.getBaseUrl() + "/DataPackages/Upload", config.getReadTimeout())
.header("Content-Type", "application/json")
.header("Authorization", basicAuth(config))
.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) {
throw new LegalRequirementsUploadException("LegalRequirements upload failed with status " + response.statusCode());
}
JsonNode root = objectMapper.readTree(response.body());
String dataPackageId = text(root, "ID");
if (dataPackageId == null) {
dataPackageId = text(root, "Id");
}
if (dataPackageId == null && root.has("value") && root.get("value").isObject()) {
dataPackageId = text(root.get("value"), "DataPackageID");
}
if (dataPackageId == null) {
throw new LegalRequirementsUploadException("LegalRequirements upload response did not contain DataPackageID.");
}
return dataPackageId;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LegalRequirementsUploadException("Failed to upload tachograph file to LegalRequirements.", e);
} catch (IOException e) {
throw new LegalRequirementsUploadException("Failed to upload tachograph file to LegalRequirements.", e);
}
}
private String downloadXml(HttpClient client, EventHubProperties.LegalRequirements config, String dataPackageId) {
try {
HttpRequest request = requestBuilder(config.getBaseUrl() + "/DataPackages/" + dataPackageId + "/Xml", config.getReadTimeout())
.header("Authorization", basicAuth(config))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) {
throw new LegalRequirementsXmlDownloadException("LegalRequirements XML download failed with status " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LegalRequirementsXmlDownloadException("Failed to download processed tachograph XML.", e);
} catch (IOException e) {
throw new LegalRequirementsXmlDownloadException("Failed to download processed tachograph XML.", e);
}
}
private void resetSessionQuietly(HttpClient client, EventHubProperties.LegalRequirements config) {
try {
HttpRequest request = requestBuilder(config.getBaseUrl() + "/LegalRequirementsResults/ResetSession", config.getReadTimeout())
.header("Authorization", basicAuth(config))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
client.send(request, HttpResponse.BodyHandlers.discarding());
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
} catch (IOException ignored) {
}
}
private HttpRequest.Builder requestBuilder(String url, Duration timeout) {
return HttpRequest.newBuilder(URI.create(url)).timeout(timeout);
}
private String basicAuth(EventHubProperties.LegalRequirements config) {
String user = config.getUsername() == null ? "" : config.getUsername();
String password = config.getPassword() == null ? "" : config.getPassword();
String token = Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8));
return "Basic " + token;
}
private String text(JsonNode node, String field) {
if (node == null || !node.has(field) || node.get(field).isNull()) {
return null;
}
return node.get(field).asText();
}
}

View File

@ -0,0 +1,12 @@
package at.procon.eventhub.tachographfilesession.service;
public class LegalRequirementsUploadException extends RuntimeException {
public LegalRequirementsUploadException(String message) {
super(message);
}
public LegalRequirementsUploadException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,7 @@
package at.procon.eventhub.tachographfilesession.service;
public record LegalRequirementsUploadResult(
String dataPackageId,
String xmlContent
) {
}

View File

@ -0,0 +1,12 @@
package at.procon.eventhub.tachographfilesession.service;
public class LegalRequirementsXmlDownloadException extends RuntimeException {
public LegalRequirementsXmlDownloadException(String message) {
super(message);
}
public LegalRequirementsXmlDownloadException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,10 @@
package at.procon.eventhub.tachographfilesession.service;
import java.util.UUID;
public class TachographFileSessionNotFoundException extends RuntimeException {
public TachographFileSessionNotFoundException(UUID sessionId) {
super("Tachograph file session not found: " + sessionId);
}
}

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

@ -0,0 +1,17 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface TachographFileSessionRepository {
TachographFileSession save(TachographFileSession session);
Optional<TachographFileSession> find(UUID sessionId);
boolean delete(UUID sessionId);
List<TachographFileSession> list();
}

View File

@ -0,0 +1,199 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto;
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.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
public class TachographFileSessionService {
private final EventHubProperties properties;
private final TachographFileSessionRepository repository;
private final LegalRequirementsClient legalRequirementsClient;
private final TachographXmlParser tachographXmlParser;
private final DriverCardXmlExtractionService driverCardExtractionService;
private final VehicleUnitXmlExtractionService vehicleUnitExtractionService;
public TachographFileSessionService(
EventHubProperties properties,
TachographFileSessionRepository repository,
LegalRequirementsClient legalRequirementsClient,
TachographXmlParser tachographXmlParser,
DriverCardXmlExtractionService driverCardExtractionService,
VehicleUnitXmlExtractionService vehicleUnitExtractionService
) {
this.properties = properties;
this.repository = repository;
this.legalRequirementsClient = legalRequirementsClient;
this.tachographXmlParser = tachographXmlParser;
this.driverCardExtractionService = driverCardExtractionService;
this.vehicleUnitExtractionService = vehicleUnitExtractionService;
}
public CreateTachographFileSessionResponse createSession(
MultipartFile file,
String tenantKey,
String sourceInstanceKey,
String sessionLabel
) {
try {
validateFile(file);
byte[] fileBytes = file.getBytes();
validateFileBytes(fileBytes);
LegalRequirementsUploadResult uploadResult = legalRequirementsClient.uploadTachographFile(fileBytes, file.getOriginalFilename());
TachographXmlParser.ParsedTachographXml parsedXml = tachographXmlParser.parse(uploadResult.xmlContent());
Instant createdAt = Instant.now();
Instant expiresAt = createdAt.plus(properties.getTachographFileSession().getTtl());
boolean driverCardFile = "DriverCard".equals(parsedXml.rootElementName());
TachographFileSessionMetadata metadata = new TachographFileSessionMetadata(
tenant(tenantKey),
sourceInstance(sourceInstanceKey, driverCardFile),
blankToNull(sessionLabel),
file.getOriginalFilename(),
sha256(fileBytes),
fileBytes.length,
uploadResult.dataPackageId(),
sha256(uploadResult.xmlContent().getBytes(StandardCharsets.UTF_8)),
driverCardFile,
null
);
TachographFileSession session = driverCardFile
? driverCardExtractionService.extract(parsedXml, metadata, createdAt, expiresAt)
: vehicleUnitExtractionService.extract(parsedXml, metadata, createdAt, expiresAt);
TachographFileSession saved = repository.save(session);
return new CreateTachographFileSessionResponse(toSummary(saved));
} catch (Exception e) {
if (e instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new IllegalStateException("Failed to create tachograph file session.", e);
}
}
public TachographFileSessionSummaryDto getSession(UUID sessionId) {
return toSummary(requireSession(sessionId));
}
public TachographFileSessionListDriversResponse listDrivers(UUID sessionId) {
TachographFileSession session = requireSession(sessionId);
return new TachographFileSessionListDriversResponse(session.sessionId(), driverSummaries(session));
}
public TachographFileDriverDetailDto getDriver(UUID sessionId, String driverKey) {
TachographFileSession session = requireSession(sessionId);
DriverExtractionSession driver = session.driversByKey().get(driverKey);
if (driver == null) {
throw new DriverNotFoundInSessionException(sessionId, driverKey);
}
return new TachographFileDriverDetailDto(
session.sessionId(),
driver.driverKey(),
driver.driver(),
driver.driverCard(),
driver.vehicleRegistrations(),
driver.vehicles(),
driver.cardVehicleUsageIntervals(),
driver.cardActivityIntervals(),
driver.supportEvents(),
driver.warnings()
);
}
public TachographFileSessionDeleteResponse deleteSession(UUID sessionId) {
return new TachographFileSessionDeleteResponse(sessionId, repository.delete(sessionId));
}
private TachographFileSession requireSession(UUID sessionId) {
return repository.find(sessionId).orElseThrow(() -> new TachographFileSessionNotFoundException(sessionId));
}
private TachographFileSessionSummaryDto toSummary(TachographFileSession session) {
return new TachographFileSessionSummaryDto(
session.sessionId(),
session.metadata().tenantKey(),
session.metadata().sourceInstanceKey(),
session.metadata().sessionLabel(),
session.metadata().originalFileName(),
session.metadata().driverCardFile(),
session.metadata().legalRequirementsDataPackageId(),
session.extractionStats(),
driverSummaries(session),
session.warnings(),
session.createdAt(),
session.expiresAt()
);
}
private List<TachographFileDriverSummaryDto> driverSummaries(TachographFileSession session) {
return session.driversByKey().values().stream()
.sorted(Comparator.comparing(DriverExtractionSession::driverKey))
.map(driver -> new TachographFileDriverSummaryDto(
driver.driverKey(),
driver.driver() == null ? null : driver.driver().surname(),
driver.driver() == null ? null : driver.driver().firstNames(),
driver.driverCard() == null ? null : driver.driverCard().cardNation(),
driver.driverCard() == null ? null : driver.driverCard().cardNumber(),
driver.cardActivityIntervals().size(),
driver.cardVehicleUsageIntervals().size()
))
.toList();
}
private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() == 0L) {
throw new IllegalArgumentException("Tachograph file must not be empty.");
}
if (file.getSize() > properties.getTachographFileSession().getMaxFileSizeBytes()) {
throw new IllegalArgumentException("Tachograph file exceeds the configured size limit.");
}
}
private void validateFileBytes(byte[] fileBytes) {
if (fileBytes == null || fileBytes.length == 0) {
throw new IllegalArgumentException("Tachograph file must not be empty.");
}
if (fileBytes.length > properties.getTachographFileSession().getMaxFileSizeBytes()) {
throw new IllegalArgumentException("Tachograph file exceeds the configured size limit.");
}
}
private String tenant(String tenantKey) {
return blankToNull(tenantKey) == null ? "default" : tenantKey.trim();
}
private String sourceInstance(String sourceInstanceKey, boolean driverCardFile) {
if (blankToNull(sourceInstanceKey) != null) {
return sourceInstanceKey.trim();
}
return driverCardFile ? "legalrequirements-drivercard" : "legalrequirements-vehicleunit";
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String sha256(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 digest is not available.", e);
}
}
}

View File

@ -0,0 +1,35 @@
package at.procon.eventhub.tachographfilesession.service;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
final class TachographTimeParser {
private TachographTimeParser() {
}
static OffsetDateTime combineUtc(LocalDate date, String timeText) {
if (date == null || timeText == null || timeText.isBlank()) {
return null;
}
String normalized = timeText.trim();
try {
OffsetTime offsetTime = OffsetTime.parse(normalized);
return date.atTime(offsetTime.toLocalTime())
.atOffset(offsetTime.getOffset())
.withOffsetSameInstant(ZoneOffset.UTC);
} catch (DateTimeParseException ignored) {
}
try {
return date.atTime(LocalTime.parse(normalized)).atOffset(ZoneOffset.UTC);
} catch (DateTimeParseException ignored) {
return null;
}
}
}

View File

@ -0,0 +1,90 @@
package at.procon.eventhub.tachographfilesession.service;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
@Component
public class TachographXmlParser {
private static final String JAXP_MAX_OCCUR_LIMIT = "http://www.oracle.com/xml/jaxp/properties/maxOccurLimit";
private final Schema schema;
public TachographXmlParser() {
this.schema = loadSchema();
}
public ParsedTachographXml parse(String xmlContent) {
try {
String normalizedXmlContent = normalizeXmlContent(xmlContent);
validate(normalizedXmlContent);
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder();
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.");
}
return new ParsedTachographXml(document, rootName);
} catch (ParserConfigurationException | IOException | SAXException e) {
throw new TachographXmlValidationException("Failed to parse tachograph XML.", e);
}
}
private void validate(String xmlContent) {
try {
schema.newValidator().validate(new StreamSource(new StringReader(xmlContent)));
} catch (IOException | SAXException e) {
throw new TachographXmlValidationException("Tachograph XML failed XSD validation.", e);
}
}
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);
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
factory.setProperty(JAXP_MAX_OCCUR_LIMIT, 100_000);
byte[] content = new ClassPathResource("xsd/Tachograph.xsd").getContentAsByteArray();
return factory.newSchema(new StreamSource(new ByteArrayInputStream(content)));
} catch (IOException | SAXException e) {
throw new IllegalStateException("Failed to load Tachograph XSD schema.", e);
}
}
public record ParsedTachographXml(
Document document,
String rootElementName
) {
}
}

View File

@ -0,0 +1,12 @@
package at.procon.eventhub.tachographfilesession.service;
public class TachographXmlValidationException extends RuntimeException {
public TachographXmlValidationException(String message) {
super(message);
}
public TachographXmlValidationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,8 @@
package at.procon.eventhub.tachographfilesession.service;
public class UnsupportedTachographFileTypeException extends RuntimeException {
public UnsupportedTachographFileTypeException(String message) {
super(message);
}
}

View File

@ -0,0 +1,32 @@
package at.procon.eventhub.tachographfilesession.service;
import org.springframework.stereotype.Component;
@Component
public class VehicleKeyFactory {
public String createRegistrationKey(String registrationNation, String registrationNumber) {
String normalizedNation = normalize(registrationNation, "UNKNOWN");
String normalizedNumber = normalize(registrationNumber, "UNKNOWN");
return normalizedNation + ":" + normalizedNumber;
}
public String createSourceVehicleRegistrationId(String registrationKey) {
return "VR:" + registrationKey;
}
public String createVehicleKey(String vin) {
return normalize(vin, null);
}
public String createSourceVehicleId(String vehicleKey) {
return vehicleKey == null ? null : "VIN:" + vehicleKey;
}
private String normalize(String value, String fallback) {
if (value == null || value.isBlank()) {
return fallback;
}
return value.trim();
}
}

View File

@ -0,0 +1,925 @@
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.ExtractedDriver;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.UUID;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
@Component
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;
this.vehicleKeyFactory = vehicleKeyFactory;
}
public TachographFileSession extract(
TachographXmlParser.ParsedTachographXml parsedXml,
TachographFileSessionMetadata metadata,
Instant createdAt,
Instant expiresAt
) {
Document document = parsedXml.document();
List<ExtractionWarning> sessionWarnings = new ArrayList<>();
VehicleContext vehicleContext = extractVehicleContext(document, sessionWarnings);
Map<String, DriverExtractionBuilder> driversByKey = new LinkedHashMap<>();
List<VuCardIwInterval> vuCardIwIntervals = new ArrayList<>();
NodeList records = nodes(document, "/VehicleUnit/Activities/vuCardIWData/vuCardIWRecords");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/Activities/vuCardIWData/vuCardIWRecords[" + (i + 1) + "]";
String driverIdentification = text(record, "fullCardNumber/cardNumber/driverIdentification");
if (driverIdentification == null) {
continue;
}
String cardNation = text(record, "fullCardNumber/cardIssuingMemberState");
String cardNumber = joinCardNumber(record, "fullCardNumber/cardNumber");
if (cardNumber == null) {
sessionWarnings.add(new ExtractionWarning(
"MISSING_VU_DRIVER_CARD",
"Vehicle-unit insertion/withdrawal record is missing a usable driver card number.",
path
));
continue;
}
String driverKey = driverKeyFactory.createDriverKey(cardNation, cardNumber);
DriverExtractionBuilder builder = driversByKey.computeIfAbsent(
driverKey,
ignored -> new DriverExtractionBuilder(driverKey, driverKeyFactory.createSourceDriverId(driverKey))
);
builder.mergeDriver(
text(record, "cardHolderName/holderSurname/name"),
text(record, "cardHolderName/holderFirstNames/name")
);
builder.mergeDriverCard(
driverKeyFactory.createSourceDriverCardId(driverKey),
cardNation,
cardNumber,
null,
null,
null,
offsetDateTime(text(record, "cardExpiryDate"))
);
builder.addVehicleContext(vehicleContext.registration(), vehicleContext.vehicle());
OffsetDateTime from = offsetDateTime(text(record, "cardInsertionTime"));
if (from == null) {
sessionWarnings.add(new ExtractionWarning(
"MISSING_VU_CARD_INSERTION",
"Vehicle-unit insertion/withdrawal record is missing cardInsertionTime.",
path
));
continue;
}
OffsetDateTime to = offsetDateTime(text(record, "cardWithdrawalTime"));
if (to == null) {
to = vehicleContext.defaultOpenIntervalEnd();
sessionWarnings.add(new ExtractionWarning(
"OPEN_VU_CARD_INTERVAL",
"Vehicle-unit insertion/withdrawal record has no withdrawal time; interval was closed using the VU downloadable-period end.",
path
));
}
if (to == null || to.isBefore(from)) {
sessionWarnings.add(new ExtractionWarning(
"INVALID_VU_CARD_INTERVAL",
"Vehicle-unit insertion/withdrawal record has an invalid interval range.",
path
));
continue;
}
builder.vehicleUsageIntervals.add(new ExtractedCardVehicleUsageInterval(
"VUIW-" + (i + 1),
from,
to,
longValue(text(record, "vehicleOdometerValueAtInsertion")),
longValue(text(record, "vehicleOdometerValueAtWithdrawal")),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
path
));
vuCardIwIntervals.add(new VuCardIwInterval(
driverKey,
normalizeToken(text(record, "cardSlotNumber")),
from,
to,
path
));
}
if (driversByKey.isEmpty()) {
sessionWarnings.add(new ExtractionWarning(
"NO_DRIVER_CARD_IW_DATA",
"Vehicle-unit XML did not contain driver-card insertion/withdrawal records to build driver sessions.",
"/VehicleUnit/Activities"
));
}
List<ExtractedCardActivityInterval> vuActivityIntervals = extractActivityIntervals(document, vehicleContext, sessionWarnings);
assignActivityCoverage(vuActivityIntervals, vuCardIwIntervals, vehicleContext, driversByKey, sessionWarnings);
extractSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, sessionWarnings);
List<ExtractionWarning> allWarnings = new ArrayList<>(sessionWarnings);
Map<String, DriverExtractionSession> driverSessions = new LinkedHashMap<>();
int activityCount = 0;
int vehicleUsageCount = 0;
for (DriverExtractionBuilder builder : driversByKey.values()) {
builder.vehicleUsageIntervals.sort(Comparator.comparing(ExtractedCardVehicleUsageInterval::from));
allWarnings.addAll(builder.warnings);
activityCount += builder.cardActivityIntervals.size();
vehicleUsageCount += builder.vehicleUsageIntervals.size();
driverSessions.put(builder.driverKey, builder.build());
}
int registrationCount = vehicleContext.registration() == null ? 0 : 1;
int vehicleCount = vehicleContext.vehicle() == null ? 0 : 1;
ExtractionStats stats = new ExtractionStats(
driverSessions.size(),
activityCount,
vehicleUsageCount,
registrationCount,
vehicleCount,
allWarnings.size()
);
return new TachographFileSession(
UUID.randomUUID(),
metadata,
Map.copyOf(driverSessions),
stats,
List.copyOf(allWarnings),
createdAt,
expiresAt
);
}
private VehicleContext extractVehicleContext(Document document, List<ExtractionWarning> warnings) {
Element overview = firstElement(document, "/VehicleUnit/Overview[1]");
if (overview == null) {
warnings.add(new ExtractionWarning(
"MISSING_VU_OVERVIEW",
"Vehicle-unit XML does not contain an Overview block.",
"/VehicleUnit"
));
return new VehicleContext(null, null, null, null);
}
String registrationNation = text(overview, "vehicleRegistrationIdentification/vehicleRegistrationNation");
String registrationNumber = text(overview, "vehicleRegistrationIdentification/vehicleRegistrationNumber/vehicleRegNumber");
ExtractedVehicleRegistration registration = null;
String registrationKey = registrationNation == null && registrationNumber == null
? null
: vehicleKeyFactory.createRegistrationKey(registrationNation, registrationNumber);
if (registrationKey != null) {
registration = new ExtractedVehicleRegistration(
registrationKey,
vehicleKeyFactory.createSourceVehicleRegistrationId(registrationKey),
registrationNation,
registrationNumber
);
}
String vin = text(overview, "vehicleIdentificationNumber");
ExtractedVehicle vehicle = null;
String vehicleKey = vehicleKeyFactory.createVehicleKey(vin);
if (vehicleKey != null) {
vehicle = new ExtractedVehicle(
vehicleKey,
vehicleKeyFactory.createSourceVehicleId(vehicleKey),
vin
);
}
OffsetDateTime defaultEnd = offsetDateTime(text(overview, "vuDownloadablePeriod/maxDownloadableTime"));
OffsetDateTime defaultStart = offsetDateTime(text(overview, "vuDownloadablePeriod/minDownloadableTime"));
return new VehicleContext(registration, vehicle, defaultStart, defaultEnd);
}
private List<ExtractedCardActivityInterval> extractActivityIntervals(
Document document,
VehicleContext vehicleContext,
List<ExtractionWarning> warnings
) {
NodeList dayRecords = nodes(document, "/VehicleUnit/Activities/vuActivityDailyData");
if (dayRecords.getLength() == 0) {
return List.of();
}
LocalDate startDate = vehicleContext.defaultActivityStartDate();
if (startDate == null) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_DATE_INFERENCE_FAILED",
"Vehicle-unit activity daily data is present but no base date could be inferred from the VU downloadable period.",
"/VehicleUnit/Activities/vuActivityDailyData"
));
return List.of();
}
LocalDate maxDate = vehicleContext.defaultActivityEndDate();
if (maxDate != null) {
LocalDate inferredEndDate = startDate.plusDays(dayRecords.getLength() - 1L);
if (inferredEndDate.isAfter(maxDate)) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_DATE_INFERENCE_RANGE",
"Vehicle-unit activity daily records exceed the VU downloadable-period range when mapped by sequence order.",
"/VehicleUnit/Activities/vuActivityDailyData"
));
}
}
List<ExtractedCardActivityInterval> intervals = new ArrayList<>();
int intervalNo = 0;
for (int dayIndex = 0; dayIndex < dayRecords.getLength(); dayIndex++) {
Element dayRecord = (Element) dayRecords.item(dayIndex);
LocalDate date = startDate.plusDays(dayIndex);
String dayPath = "/VehicleUnit/Activities[" + (dayIndex + 1) + "]/vuActivityDailyData";
NodeList changes = nodes(dayRecord, "activityChangeInfos");
List<ActivityChange> parsedChanges = new ArrayList<>();
for (int changeIndex = 0; changeIndex < changes.getLength(); changeIndex++) {
Element change = (Element) changes.item(changeIndex);
OffsetDateTime from = combine(date, text(change, "timeOfChange"));
if (from == null) {
warnings.add(new ExtractionWarning(
"INVALID_VU_ACTIVITY_CHANGE_TIME",
"Vehicle-unit activity change has invalid timeOfChange.",
dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
));
continue;
}
parsedChanges.add(new ActivityChange(
from,
normalizeActivity(text(change, "activity")),
normalizeToken(text(change, "slot")),
normalizeToken(text(change, "cardStatus")),
normalizeToken(text(change, "drivingStatus")),
dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
));
}
parsedChanges.sort(Comparator.comparing(ActivityChange::from));
for (int i = 0; i < parsedChanges.size(); i++) {
ActivityChange current = parsedChanges.get(i);
OffsetDateTime to = i + 1 < parsedChanges.size()
? parsedChanges.get(i + 1).from()
: date.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC);
if (!current.from().isBefore(to)) {
continue;
}
intervalNo++;
intervals.add(new ExtractedCardActivityInterval(
"VUACT-" + intervalNo,
current.from(),
to,
current.activityType(),
current.slot(),
current.cardStatus(),
current.drivingStatus(),
null,
null,
current.rawRecordPath()
));
}
}
intervals.sort(Comparator.comparing(ExtractedCardActivityInterval::from));
return intervals;
}
private void assignActivityCoverage(
List<ExtractedCardActivityInterval> vuActivityIntervals,
List<VuCardIwInterval> vuCardIwIntervals,
VehicleContext vehicleContext,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
for (ExtractedCardActivityInterval interval : vuActivityIntervals) {
List<ActivitySegment> segments = splitByDriverCoverage(interval, vuCardIwIntervals);
if (segments.isEmpty()) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_UNASSIGNED",
"Vehicle-unit activity interval could not be assigned to a driver-card insertion/withdrawal interval.",
interval.rawRecordPath()
));
continue;
}
if (isPartiallyCovered(interval, segments)) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_UNASSIGNED",
"Vehicle-unit activity interval could only be partially assigned to driver-card insertion/withdrawal intervals.",
interval.rawRecordPath()
));
}
for (int i = 0; i < segments.size(); i++) {
ActivitySegment segment = segments.get(i);
DriverExtractionBuilder builder = driversByKey.get(segment.driverKey());
if (builder == null) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_DRIVER_MISSING",
"Vehicle-unit activity interval matched a driver key without an initialized driver session.",
interval.rawRecordPath()
));
continue;
}
String intervalId = segments.size() == 1 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1);
builder.cardActivityIntervals.add(new ExtractedCardActivityInterval(
intervalId,
segment.from(),
segment.to(),
interval.activityType(),
interval.slot(),
interval.cardStatus(),
interval.drivingStatus(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
interval.rawRecordPath()
));
}
}
}
private void extractSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
extractVuPlaceSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuGnssSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
extractVuSpecificConditionSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
}
private void extractVuPlaceSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/VehicleUnit/Activities/vuPlaceDailyWorkPeriodData/vuPlaceDailyWorkPeriodRecords");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/Activities/vuPlaceDailyWorkPeriodData/vuPlaceDailyWorkPeriodRecords[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(text(record, "placeRecord/entryTime"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("VU_PLACE_MISSING_TIME", "Vehicle-unit place record is missing entryTime.", path));
continue;
}
String entryType = text(record, "placeRecord/entryTypeDailyWorkPeriod");
List<DriverAssignment> assignments = resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "fullCardNumber"),
null,
vuCardIwIntervals
);
if (assignments.isEmpty()) {
warnings.add(new ExtractionWarning("VU_PLACE_UNASSIGNED", "Vehicle-unit place record could not be assigned to a driver session.", path));
continue;
}
if (assignments.size() > 1) {
warnings.add(new ExtractionWarning("VU_PLACE_AMBIGUOUS", "Vehicle-unit place record matched multiple active driver sessions.", path));
}
BigDecimal latitude = geoCoordinate(record, "placeRecord/entryGnssPlaceRecord/geoCoordinates/latitude", true);
BigDecimal longitude = geoCoordinate(record, "placeRecord/entryGnssPlaceRecord/geoCoordinates/longitude", false);
String authenticationStatus = normalizeToken(text(record, "placeRecord/entryGnssPlaceRecord/authenticationStatus"));
for (DriverAssignment assignment : assignments) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VUPLACE-" + (i + 1) + "-" + assignment.driverKey(),
occurredAt,
"PLACE",
mapPlaceEntryType(entryType),
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
text(record, "placeRecord/dailyWorkPeriodCountry"),
text(record, "placeRecord/dailyWorkPeriodRegion"),
latitude,
longitude,
authenticationStatus,
longValue(text(record, "placeRecord/vehicleOdometerValue")),
normalizeToken(entryType),
path
),
warnings
);
}
}
}
private void extractVuGnssSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/VehicleUnit/Activities/vuGnssADData/vuGnssADRecord");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/Activities/vuGnssADData/vuGnssADRecord[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(text(record, "timeStamp"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("VU_GNSS_MISSING_TIME", "Vehicle-unit GNSS record is missing timeStamp.", path));
continue;
}
List<DriverAssignment> assignments = new ArrayList<>();
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberDriverSlot"),
"DRIVER",
vuCardIwIntervals
));
assignments.addAll(resolveDriverAssignments(
occurredAt,
driverKeyFromCardNode(record, "cardNumberCodriverSlot"),
"CO_DRIVER",
vuCardIwIntervals
));
assignments = distinctAssignments(assignments);
if (assignments.isEmpty()) {
warnings.add(new ExtractionWarning("VU_GNSS_UNASSIGNED", "Vehicle-unit GNSS record could not be assigned to a driver session.", path));
continue;
}
BigDecimal latitude = geoCoordinate(record, "gnssPlaceRecord/geoCoordinates/latitude", true);
BigDecimal longitude = geoCoordinate(record, "gnssPlaceRecord/geoCoordinates/longitude", false);
String authenticationStatus = normalizeToken(text(record, "gnssPlaceRecord/authenticationStatus"));
for (DriverAssignment assignment : assignments) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VUGNSS-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt,
"POSITION",
"GNSS_ACCUMULATED_DRIVING",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
latitude,
longitude,
authenticationStatus,
longValue(text(record, "vehicleOdometerValue")),
null,
path
),
warnings
);
}
}
}
private void extractVuSpecificConditionSupportEvents(
Document document,
VehicleContext vehicleContext,
List<VuCardIwInterval> vuCardIwIntervals,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/VehicleUnit/Activities/vuSpecificConditionData/specificConditionRecords");
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/VehicleUnit/Activities/vuSpecificConditionData/specificConditionRecords[" + (i + 1) + "]";
OffsetDateTime occurredAt = offsetDateTime(text(record, "entryTime"));
if (occurredAt == null) {
warnings.add(new ExtractionWarning("VU_SPECIFIC_CONDITION_MISSING_TIME", "Vehicle-unit specific-condition record is missing entryTime.", path));
continue;
}
List<DriverAssignment> assignments = distinctAssignments(resolveDriverAssignments(
occurredAt,
null,
null,
vuCardIwIntervals
));
if (assignments.isEmpty()) {
warnings.add(new ExtractionWarning("VU_SPECIFIC_CONDITION_UNASSIGNED", "Vehicle-unit specific-condition record could not be assigned to an active driver session.", path));
continue;
}
if (assignments.size() > 1) {
warnings.add(new ExtractionWarning("VU_SPECIFIC_CONDITION_AMBIGUOUS", "Vehicle-unit specific-condition record matched multiple active driver sessions.", path));
}
String conditionCode = normalizeToken(text(record, "specificConditionType"));
for (DriverAssignment assignment : assignments) {
addSupportEvent(
driversByKey,
assignment,
new ExtractedSupportEvent(
"VUSC-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
occurredAt,
"SPECIFIC_CONDITION",
"SPECIFIC_CONDITION",
assignment.slot(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
null,
null,
null,
null,
null,
null,
conditionCode,
path
),
warnings
);
}
}
}
private boolean isPartiallyCovered(ExtractedCardActivityInterval interval, List<ActivitySegment> segments) {
if (segments.isEmpty()) {
return true;
}
if (!segments.get(0).from().equals(interval.from())) {
return true;
}
if (!segments.get(segments.size() - 1).to().equals(interval.to())) {
return true;
}
for (int i = 1; i < segments.size(); i++) {
if (!segments.get(i - 1).to().equals(segments.get(i).from())) {
return true;
}
}
return false;
}
private List<ActivitySegment> splitByDriverCoverage(
ExtractedCardActivityInterval interval,
List<VuCardIwInterval> vuCardIwIntervals
) {
TreeSet<OffsetDateTime> cutPoints = new TreeSet<>();
cutPoints.add(interval.from());
cutPoints.add(interval.to());
List<VuCardIwInterval> matchingIntervals = vuCardIwIntervals.stream()
.filter(iw -> iw.slot() == null || interval.slot() == null || iw.slot().equals(interval.slot()))
.filter(iw -> iw.overlaps(interval.from(), interval.to()))
.toList();
for (VuCardIwInterval iw : matchingIntervals) {
if (iw.from().isAfter(interval.from()) && iw.from().isBefore(interval.to())) {
cutPoints.add(iw.from());
}
OffsetDateTime iwEndExclusive = iw.endExclusive();
if (iwEndExclusive.isAfter(interval.from()) && iwEndExclusive.isBefore(interval.to())) {
cutPoints.add(iwEndExclusive);
}
}
List<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints);
List<ActivitySegment> segments = new ArrayList<>();
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;
}
VuCardIwInterval covering = matchingIntervals.stream()
.filter(iw -> iw.covers(segmentFrom))
.findFirst()
.orElse(null);
if (covering != null) {
segments.add(new ActivitySegment(covering.driverKey(), segmentFrom, segmentTo));
}
}
return segments;
}
private void addSupportEvent(
Map<String, DriverExtractionBuilder> driversByKey,
DriverAssignment assignment,
ExtractedSupportEvent supportEvent,
List<ExtractionWarning> warnings
) {
DriverExtractionBuilder builder = driversByKey.get(assignment.driverKey());
if (builder == null) {
warnings.add(new ExtractionWarning(
"VU_SUPPORT_EVENT_DRIVER_MISSING",
"Support event matched a driver key without an initialized driver session.",
supportEvent.rawRecordPath()
));
return;
}
builder.supportEvents.add(supportEvent);
}
private List<DriverAssignment> resolveDriverAssignments(
OffsetDateTime occurredAt,
String explicitDriverKey,
String explicitSlot,
List<VuCardIwInterval> vuCardIwIntervals
) {
if (explicitDriverKey != null) {
return List.of(new DriverAssignment(explicitDriverKey, explicitSlot));
}
return vuCardIwIntervals.stream()
.filter(iw -> explicitSlot == null || explicitSlot.equals(iw.slot()))
.filter(iw -> iw.covers(occurredAt))
.map(iw -> new DriverAssignment(iw.driverKey(), iw.slot()))
.toList();
}
private List<DriverAssignment> distinctAssignments(List<DriverAssignment> assignments) {
Map<String, DriverAssignment> unique = new LinkedHashMap<>();
for (DriverAssignment assignment : assignments) {
unique.putIfAbsent(assignment.driverKey() + "|" + assignment.slot(), assignment);
}
return List.copyOf(unique.values());
}
private String driverKeyFromCardNode(Element node, String basePath) {
String cardNation = text(node, basePath + "/cardIssuingMemberState");
String cardNumber = joinCardNumber(node, basePath + "/cardNumber");
return cardNumber == null ? null : driverKeyFactory.createDriverKey(cardNation, cardNumber);
}
private Element firstElement(Object node, String expression) {
NodeList nodes = nodes(node, expression);
if (nodes.getLength() == 0) {
return null;
}
return (Element) nodes.item(0);
}
private NodeList nodes(Object node, String expression) {
return xml.nodes(node, expression);
}
private String text(Object node, String expression) {
return xml.text(node, expression);
}
private OffsetDateTime offsetDateTime(String value) {
if (value == null || value.isBlank()) {
return null;
}
return OffsetDateTime.parse(value.trim()).withOffsetSameInstant(ZoneOffset.UTC);
}
private Long longValue(String value) {
if (value == null || value.isBlank()) {
return null;
}
return Long.parseLong(value.trim());
}
private BigDecimal geoCoordinate(Object node, String expression, boolean latitude) {
String value = text(node, expression);
if (value == null) {
return null;
}
BigDecimal raw = new BigDecimal(value);
BigDecimal abs = raw.abs();
BigDecimal degreeThreshold = BigDecimal.valueOf(latitude ? 90D : 180D);
if (abs.compareTo(degreeThreshold) <= 0) {
return raw;
}
BigDecimal sign = raw.signum() < 0 ? BigDecimal.valueOf(-1L) : BigDecimal.ONE;
BigDecimal absolute = raw.abs();
BigDecimal[] degreeAndRemainder = absolute.divideAndRemainder(BigDecimal.valueOf(1000L));
BigDecimal degrees = degreeAndRemainder[0];
BigDecimal minutes = degreeAndRemainder[1].divide(BigDecimal.TEN);
BigDecimal decimalDegrees = degrees.add(minutes.divide(BigDecimal.valueOf(60L), 8, java.math.RoundingMode.HALF_UP));
return decimalDegrees.multiply(sign);
}
private OffsetDateTime combine(LocalDate date, String timeText) {
return TachographTimeParser.combineUtc(date, timeText);
}
private String normalizeActivity(String value) {
String normalized = normalizeToken(value);
if (normalized == null) {
return "UNKNOWN_ACTIVITY";
}
return switch (normalized) {
case "DRIVING", "DRIVE" -> "DRIVE";
case "WORK" -> "WORK";
case "AVAILABILITY", "AVAILABLE" -> "AVAILABILITY";
case "BREAK_REST", "BREAK/REST", "REST" -> "BREAK_REST";
default -> "UNKNOWN_ACTIVITY";
};
}
private String normalizeToken(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim().toUpperCase().replace('-', '_').replace(' ', '_');
}
private String mapPlaceEntryType(String entryType) {
String normalized = normalizeToken(entryType);
if (normalized == null) {
return "DAILY_WORK_PERIOD_PLACE";
}
return switch (normalized) {
case "0" -> "BEGIN_DAILY_WORK_PERIOD";
case "1" -> "END_DAILY_WORK_PERIOD";
case "2" -> "BEGIN_MANUAL_DAILY_WORK_PERIOD";
case "3" -> "END_MANUAL_DAILY_WORK_PERIOD";
case "4" -> "BEGIN_ASSUMED_DAILY_WORK_PERIOD";
case "5" -> "END_ASSUMED_DAILY_WORK_PERIOD";
case "6" -> "BEGIN_GNSS_DAILY_WORK_PERIOD";
case "7" -> "END_GNSS_DAILY_WORK_PERIOD";
default -> "DAILY_WORK_PERIOD_PLACE";
};
}
private String joinCardNumber(Element node, String basePath) {
String driverIdentification = text(node, basePath + "/driverIdentification");
if (driverIdentification == null) {
return null;
}
String replacement = text(node, basePath + "/cardReplacementIndex");
String renewal = text(node, basePath + "/cardRenewalIndex");
StringBuilder builder = new StringBuilder(driverIdentification);
if (replacement != null) {
builder.append(replacement);
}
if (renewal != null) {
builder.append(renewal);
}
return builder.toString();
}
private static final class DriverExtractionBuilder {
private final String driverKey;
private final String sourceDriverId;
private ExtractedDriver driver;
private ExtractedDriverCard driverCard;
private final Map<String, ExtractedVehicleRegistration> registrationsByKey = new LinkedHashMap<>();
private final Map<String, ExtractedVehicle> vehiclesByKey = new LinkedHashMap<>();
private final List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals = new ArrayList<>();
private final List<ExtractedCardActivityInterval> cardActivityIntervals = new ArrayList<>();
private final List<ExtractedSupportEvent> supportEvents = new ArrayList<>();
private final List<ExtractionWarning> warnings = new ArrayList<>();
private DriverExtractionBuilder(String driverKey, String sourceDriverId) {
this.driverKey = driverKey;
this.sourceDriverId = sourceDriverId;
}
private void mergeDriver(String surname, String firstNames) {
if (driver == null) {
driver = new ExtractedDriver(
driverKey,
sourceDriverId,
surname,
firstNames,
null,
null,
null,
null,
null
);
}
}
private void mergeDriverCard(
String sourceDriverCardId,
String cardNation,
String cardNumber,
String issuingAuthorityName,
OffsetDateTime issueDate,
OffsetDateTime validityBegin,
OffsetDateTime expiryDate
) {
if (driverCard == null) {
driverCard = new ExtractedDriverCard(
sourceDriverCardId,
cardNation,
cardNumber,
issuingAuthorityName,
issueDate,
validityBegin,
expiryDate
);
}
}
private void addVehicleContext(ExtractedVehicleRegistration registration, ExtractedVehicle vehicle) {
if (registration != null) {
registrationsByKey.putIfAbsent(registration.registrationKey(), registration);
}
if (vehicle != null) {
vehiclesByKey.putIfAbsent(vehicle.vehicleKey(), vehicle);
}
}
private DriverExtractionSession build() {
return new DriverExtractionSession(
driverKey,
driver,
driverCard,
List.copyOf(registrationsByKey.values()),
List.copyOf(vehiclesByKey.values()),
List.copyOf(vehicleUsageIntervals),
List.copyOf(cardActivityIntervals),
List.copyOf(supportEvents),
List.copyOf(warnings)
);
}
}
private record VehicleContext(
ExtractedVehicleRegistration registration,
ExtractedVehicle vehicle,
OffsetDateTime defaultActivityStart,
OffsetDateTime defaultOpenIntervalEnd
) {
private LocalDate defaultActivityStartDate() {
return defaultActivityStart == null ? null : defaultActivityStart.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate();
}
private LocalDate defaultActivityEndDate() {
return defaultOpenIntervalEnd == null ? null : defaultOpenIntervalEnd.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate();
}
}
private record ActivityChange(
OffsetDateTime from,
String activityType,
String slot,
String cardStatus,
String drivingStatus,
String rawRecordPath
) {
}
private record VuCardIwInterval(
String driverKey,
String slot,
OffsetDateTime from,
OffsetDateTime to,
String rawRecordPath
) {
private OffsetDateTime endExclusive() {
return to.plusSeconds(1);
}
private boolean covers(OffsetDateTime timestamp) {
return !from.isAfter(timestamp) && timestamp.isBefore(endExclusive());
}
private boolean overlaps(OffsetDateTime rangeStart, OffsetDateTime rangeEnd) {
return endExclusive().isAfter(rangeStart) && from.isBefore(rangeEnd);
}
}
private record ActivitySegment(
String driverKey,
OffsetDateTime from,
OffsetDateTime to
) {
}
private record DriverAssignment(
String driverKey,
String slot
) {
}
}

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

@ -121,6 +121,23 @@ eventhub:
initial-occurred-to: "2026-04-10T00:00:00+01:00"
run-initial-on-startup: true
tachograph-file-session:
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:}
password: ${LEGAL_REQUIREMENTS_PASSWORD:}
connect-timeout: 20s
read-timeout: 2m
reset-session-after-use: false
esper-poc:
activity-merge-mode: JAVA
shift-resolution-mode: JAVA

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,34 @@
package at.procon.eventhub.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Test;
class EventHubPropertiesTest {
@Test
void legalRequirementsBaseUrlIsNormalizedWhenPrefixedWithColonAndWhitespace() {
EventHubProperties.LegalRequirements legalRequirements = new EventHubProperties.LegalRequirements();
legalRequirements.setBaseUrl(": https://legalrequirements.services.bytebar.eu/ODataV4/LR/");
assertEquals("https://legalrequirements.services.bytebar.eu/ODataV4/LR", legalRequirements.getBaseUrl());
}
@Test
void legalRequirementsBaseUrlFallsBackToDefaultWhenBlank() {
EventHubProperties.LegalRequirements legalRequirements = new EventHubProperties.LegalRequirements();
legalRequirements.setBaseUrl(" ");
assertEquals("https://legalrequirements.services.bytebar.eu/ODataV4/LR", legalRequirements.getBaseUrl());
}
@Test
void legalRequirementsResetSessionIsDisabledByDefault() {
EventHubProperties.LegalRequirements legalRequirements = new EventHubProperties.LegalRequirements();
assertFalse(legalRequirements.isResetSessionAfterUse());
}
}

View File

@ -0,0 +1,122 @@
package at.procon.eventhub.tachographfilesession.api;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
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;
import java.util.UUID;
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);
TachographFileSessionProcessingService processingService = org.mockito.Mockito.mock(TachographFileSessionProcessingService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TachographFileSessionController(service, processingService))
.setControllerAdvice(new TachographFileSessionExceptionHandler())
.build();
UUID sessionId = UUID.randomUUID();
TachographFileDriverSummaryDto driver = new TachographFileDriverSummaryDto("12:123", "Muster", "Max", "12", "123", 3, 2);
TachographFileSessionSummaryDto summary = new TachographFileSessionSummaryDto(
sessionId,
"default",
"legalrequirements-drivercard",
"sample",
"sample.ddd",
true,
"42",
new ExtractionStats(1, 3, 2, 2, 2, 0),
List.of(driver),
List.of(),
Instant.parse("2026-05-12T10:00:00Z"),
Instant.parse("2026-05-12T14:00:00Z")
);
when(service.createSession(org.mockito.ArgumentMatchers.any(), eq("default"), eq("src"), eq("sample")))
.thenReturn(new CreateTachographFileSessionResponse(summary));
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")
.file(new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes()))
.param("tenantKey", "default")
.param("sourceInstanceKey", "src")
.param("sessionLabel", "sample"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.session.sessionId").value(sessionId.toString()));
mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.sessionId").value(sessionId.toString()));
mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers", sessionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.drivers[0].driverKey").value("12:123"));
mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}", sessionId, "12:123"))
.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));
}
}

View File

@ -0,0 +1,82 @@
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.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import org.junit.jupiter.api.Test;
class DriverCardXmlExtractionServiceTest {
private final TachographXmlParser parser = new TachographXmlParser();
private final DriverCardXmlExtractionService service = new DriverCardXmlExtractionService(
new DriverKeyFactory(),
new VehicleKeyFactory()
);
@Test
void extractsDriverCardVehiclesAndActivities() {
TachographFileSession session = service.extract(
parser.parse(DriverCardXmlSamples.validDriverCardXml()),
new TachographFileSessionMetadata(
"default",
"legalrequirements-drivercard",
"sample",
"sample.ddd",
"abc",
10,
"42",
"def",
true,
null
),
Instant.now(),
Instant.now().plus(4, ChronoUnit.HOURS)
);
assertThat(session.driversByKey()).hasSize(1);
DriverExtractionSession driver = session.driversByKey().values().iterator().next();
assertThat(driver.driverKey()).isEqualTo("12:12345678901200");
assertThat(driver.driver().surname()).isEqualTo("Muster");
assertThat(driver.driverCard().sourceDriverCardId()).isEqualTo("CARD:12:12345678901200");
assertThat(driver.vehicleRegistrations()).hasSize(2);
assertThat(driver.vehicles()).hasSize(2);
assertThat(driver.cardVehicleUsageIntervals()).hasSize(2);
assertThat(driver.cardActivityIntervals()).hasSize(5);
assertThat(driver.cardActivityIntervals().get(0).registrationKey()).isEqualTo("12:W-12345A");
assertThat(driver.cardActivityIntervals().get(1).to()).isEqualTo(driver.cardVehicleUsageIntervals().get(0).to().plusSeconds(1));
assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B");
assertThat(driver.cardActivityIntervals().get(3).registrationKey()).isEqualTo("12:W-54321B");
assertThat(driver.cardActivityIntervals().get(4).registrationKey()).isNull();
}
@Test
void extractsActivitiesWhenTimeOfChangeHasNoOffset() {
TachographFileSession session = service.extract(
parser.parse(DriverCardXmlSamples.validDriverCardXmlWithLocalActivityTimes()),
new TachographFileSessionMetadata(
"default",
"legalrequirements-drivercard",
"sample",
"sample.ddd",
"abc",
10,
"42",
"def",
true,
null
),
Instant.now(),
Instant.now().plus(4, ChronoUnit.HOURS)
);
DriverExtractionSession driver = session.driversByKey().values().iterator().next();
assertThat(driver.cardActivityIntervals()).hasSize(5);
assertThat(driver.cardActivityIntervals())
.extracting(interval -> interval.from().toString())
.contains("2026-04-01T08:00Z", "2026-04-01T12:30Z");
}
}

View File

@ -0,0 +1,122 @@
package at.procon.eventhub.tachographfilesession.service;
final class DriverCardXmlSamples {
private DriverCardXmlSamples() {
}
static String validDriverCardXml() {
return """
<DriverCard>
<Identification>
<cardIdentification>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>123456789012</driverIdentification>
<cardReplacementIndex>0</cardReplacementIndex>
<cardRenewalIndex>0</cardRenewalIndex>
</cardNumber>
<cardIssuingAuthorityName>
<name>Authority</name>
</cardIssuingAuthorityName>
<cardIssueDate>2026-04-01T00:00:00Z</cardIssueDate>
<cardValidityBegin>2026-04-01T00:00:00Z</cardValidityBegin>
<cardExpiryDate>2031-04-01T00:00:00Z</cardExpiryDate>
</cardIdentification>
<driverCardHolderIdentification>
<cardHolderName>
<holderSurname><name>Muster</name></holderSurname>
<holderFirstNames><name>Max</name></holderFirstNames>
</cardHolderName>
<cardHolderBirthDate>
<year>1985</year>
<month>06</month>
<day>15</day>
</cardHolderBirthDate>
<cardHolderPreferredLanguage>de</cardHolderPreferredLanguage>
</driverCardHolderIdentification>
<signature></signature>
</Identification>
<DrivingLicenseInfo>
<cardDrivingLicenseInformation>
<drivingLicenceIssuingAuthority><name>Vienna</name></drivingLicenceIssuingAuthority>
<drivingLicenceIssuingNation>12</drivingLicenceIssuingNation>
<drivingLicenceNumber>B1234567</drivingLicenceNumber>
</cardDrivingLicenseInformation>
<signature></signature>
</DrivingLicenseInfo>
<VehiclesUsed>
<cardVehiclesUsed>
<vehiclePointerNewestRecord>1</vehiclePointerNewestRecord>
<cardVehicleRecords>
<vehicleOdometerBegin>1000</vehicleOdometerBegin>
<vehicleOdometerEnd>1100</vehicleOdometerEnd>
<vehicleFirstUse>2026-04-01T08:00:00Z</vehicleFirstUse>
<vehicleLastUse>2026-04-01T11:59:59Z</vehicleLastUse>
<vehicleRegistration>
<vehicleRegistrationNation>12</vehicleRegistrationNation>
<vehicleRegistrationNumber><vehicleRegNumber>W-12345A</vehicleRegNumber></vehicleRegistrationNumber>
</vehicleRegistration>
<vuDataBlockCounter>0001</vuDataBlockCounter>
<vehicleIdentificationNumber>VIN12345678901234</vehicleIdentificationNumber>
</cardVehicleRecords>
<cardVehicleRecords>
<vehicleOdometerBegin>1101</vehicleOdometerBegin>
<vehicleOdometerEnd>1200</vehicleOdometerEnd>
<vehicleFirstUse>2026-04-01T12:00:00Z</vehicleFirstUse>
<vehicleLastUse>2026-04-01T18:00:00Z</vehicleLastUse>
<vehicleRegistration>
<vehicleRegistrationNation>12</vehicleRegistrationNation>
<vehicleRegistrationNumber><vehicleRegNumber>W-54321B</vehicleRegNumber></vehicleRegistrationNumber>
</vehicleRegistration>
<vuDataBlockCounter>0002</vuDataBlockCounter>
<vehicleIdentificationNumber>VIN99999999999999</vehicleIdentificationNumber>
</cardVehicleRecords>
</cardVehiclesUsed>
<signature></signature>
</VehiclesUsed>
<DriverActivityData>
<cardDriverActivity>
<activityPointerOldestDayRecord>0</activityPointerOldestDayRecord>
<activityPointerNewestRecord>1</activityPointerNewestRecord>
<cardActivityDailyRecord>
<activityRecordDate>2026-04-01T00:00:00Z</activityRecordDate>
<activityDailyPresenceCounter>0001</activityDailyPresenceCounter>
<activityDayDistance>200</activityDayDistance>
<noOfActivityChanges>3</noOfActivityChanges>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>WORK</activity>
<timeOfChange>08:00:00Z</timeOfChange>
</activityChangeInfos>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>DRIVING</activity>
<timeOfChange>09:00:00Z</timeOfChange>
</activityChangeInfos>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>BREAK/REST</activity>
<timeOfChange>12:30:00Z</timeOfChange>
</activityChangeInfos>
</cardActivityDailyRecord>
</cardDriverActivity>
<signature></signature>
</DriverActivityData>
</DriverCard>
""";
}
static String validDriverCardXmlWithLocalActivityTimes() {
return validDriverCardXml()
.replace("<timeOfChange>08:00:00Z</timeOfChange>", "<timeOfChange>08:00:00</timeOfChange>")
.replace("<timeOfChange>09:00:00Z</timeOfChange>", "<timeOfChange>09:00:00</timeOfChange>")
.replace("<timeOfChange>12:30:00Z</timeOfChange>", "<timeOfChange>12:30:00</timeOfChange>");
}
}

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

@ -0,0 +1,129 @@
package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.mock.web.MockMultipartFile;
class TachographFileSessionServiceTest {
@Test
void createsAndLoadsSession() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class);
VehicleUnitXmlExtractionService vehicleUnitExtractor = Mockito.mock(VehicleUnitXmlExtractionService.class);
TachographFileSessionService service = new TachographFileSessionService(
properties,
repository,
client,
parser,
driverCardExtractor,
vehicleUnitExtractor
);
MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes(StandardCharsets.UTF_8));
when(client.uploadTachographFile(any(), eq("sample.ddd"))).thenReturn(new LegalRequirementsUploadResult("42", "<DriverCard/>"));
TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
when(parsed.rootElementName()).thenReturn("DriverCard");
when(parser.parse("<DriverCard/>")).thenReturn(parsed);
TachographFileSession extracted = new TachographFileSession(
UUID.randomUUID(),
new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null),
Map.of(),
new ExtractionStats(0, 0, 0, 0, 0, 0),
java.util.List.of(),
Instant.now(),
Instant.now().plusSeconds(10)
);
when(driverCardExtractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted);
CreateTachographFileSessionResponse response = service.createSession(file, null, null, "sample");
assertThat(response.session().sessionId()).isEqualTo(extracted.sessionId());
assertThat(service.getSession(extracted.sessionId()).sessionId()).isEqualTo(extracted.sessionId());
}
@Test
void rejectsOversizedFileBeforeUpload() {
EventHubProperties properties = new EventHubProperties();
properties.getTachographFileSession().setMaxFileSizeBytes(1024);
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class);
VehicleUnitXmlExtractionService vehicleUnitExtractor = Mockito.mock(VehicleUnitXmlExtractionService.class);
TachographFileSessionService service = new TachographFileSessionService(
properties,
repository,
client,
parser,
driverCardExtractor,
vehicleUnitExtractor
);
MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", new byte[1025]);
assertThatThrownBy(() -> service.createSession(file, null, null, "sample"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("size limit");
verifyNoInteractions(client, parser, driverCardExtractor, vehicleUnitExtractor);
}
@Test
void usesVehicleUnitDefaultsAndExtractorForVehicleUnitXml() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class);
VehicleUnitXmlExtractionService vehicleUnitExtractor = Mockito.mock(VehicleUnitXmlExtractionService.class);
TachographFileSessionService service = new TachographFileSessionService(
properties,
repository,
client,
parser,
driverCardExtractor,
vehicleUnitExtractor
);
MockMultipartFile file = new MockMultipartFile("file", "vu.ddd", "application/octet-stream", "vu".getBytes(StandardCharsets.UTF_8));
when(client.uploadTachographFile(any(), eq("vu.ddd"))).thenReturn(new LegalRequirementsUploadResult("77", "<VehicleUnit/>"));
TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
when(parsed.rootElementName()).thenReturn("VehicleUnit");
when(parser.parse("<VehicleUnit/>")).thenReturn(parsed);
TachographFileSession extracted = new TachographFileSession(
UUID.randomUUID(),
new TachographFileSessionMetadata("default", "legalrequirements-vehicleunit", "vu", "vu.ddd", "a", 2, "77", "b", false, null),
Map.of(),
new ExtractionStats(0, 0, 0, 0, 0, 0),
java.util.List.of(),
Instant.now(),
Instant.now().plusSeconds(10)
);
when(vehicleUnitExtractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted);
CreateTachographFileSessionResponse response = service.createSession(file, null, null, "vu");
assertThat(response.session().driverCardFile()).isFalse();
assertThat(response.session().sourceInstanceKey()).isEqualTo("legalrequirements-vehicleunit");
}
}

View File

@ -0,0 +1,26 @@
package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
class TachographTimeParserTest {
@Test
void combinesPlainLocalTimeAsUtc() {
assertThat(TachographTimeParser.combineUtc(LocalDate.of(2026, 4, 1), "00:00:00"))
.hasToString("2026-04-01T00:00Z");
}
@Test
void preservesDateShiftWhenOffsetTimeCrossesUtcMidnight() {
assertThat(TachographTimeParser.combineUtc(LocalDate.of(2026, 4, 1), "00:30:00+02:00"))
.hasToString("2026-03-31T22:30Z");
}
@Test
void returnsNullForInvalidTimeText() {
assertThat(TachographTimeParser.combineUtc(LocalDate.of(2026, 4, 1), "not-a-time")).isNull();
}
}

View File

@ -0,0 +1,58 @@
package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class TachographXmlParserTest {
private final TachographXmlParser parser = new TachographXmlParser();
@Test
void parsesValidDriverCardXml() {
TachographXmlParser.ParsedTachographXml parsed = parser.parse(DriverCardXmlSamples.validDriverCardXml());
assertThat(parsed.rootElementName()).isEqualTo("DriverCard");
assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("DriverCard");
}
@Test
void parsesValidVehicleUnitXml() {
TachographXmlParser.ParsedTachographXml parsed = parser.parse("<VehicleUnit/>");
assertThat(parsed.rootElementName()).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
void rejectsInvalidXmlAgainstSchema() {
String invalid = "<DriverCard><Identification></DriverCard>";
assertThatThrownBy(() -> parser.parse(invalid))
.isInstanceOf(TachographXmlValidationException.class);
}
@Test
void rejectsXmlWithDoctype() {
String xmlWithDoctype = """
<!DOCTYPE DriverCard [
<!ELEMENT DriverCard ANY>
]>
<DriverCard/>
""";
assertThatThrownBy(() -> parser.parse(xmlWithDoctype))
.isInstanceOf(TachographXmlValidationException.class);
}
}

View File

@ -0,0 +1,110 @@
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.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.io.StringReader;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import javax.xml.parsers.DocumentBuilderFactory;
import org.junit.jupiter.api.Test;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;
class VehicleUnitXmlExtractionServiceTest {
private final VehicleUnitXmlExtractionService service = new VehicleUnitXmlExtractionService(
new DriverKeyFactory(),
new VehicleKeyFactory()
);
@Test
void extractsVehicleUnitDriverSessionsFromCardInsertionWithdrawalData() throws Exception {
TachographFileSession session = service.extract(
new TachographXmlParser.ParsedTachographXml(document(VehicleUnitXmlSamples.vehicleUnitXml()), "VehicleUnit"),
new TachographFileSessionMetadata(
"default",
"legalrequirements-vehicleunit",
"sample-vu",
"sample-vu.ddd",
"abc",
10,
"42",
"def",
false,
null
),
Instant.now(),
Instant.now().plus(4, ChronoUnit.HOURS)
);
assertThat(session.driversByKey()).hasSize(2);
DriverExtractionSession firstDriver = session.driversByKey().get("12:12345678901200");
DriverExtractionSession secondDriver = session.driversByKey().get("12:99999999999911");
assertThat(firstDriver).isNotNull();
assertThat(secondDriver).isNotNull();
assertThat(firstDriver.driver().surname()).isEqualTo("Muster");
assertThat(firstDriver.vehicleRegistrations()).extracting("registrationKey").containsExactly("12:W-1000V");
assertThat(firstDriver.vehicles()).extracting("vehicleKey").containsExactly("VINVU123456789012");
assertThat(firstDriver.cardVehicleUsageIntervals()).hasSize(1);
assertThat(firstDriver.cardVehicleUsageIntervals().get(0).from().toString()).isEqualTo("2026-04-01T08:00Z");
assertThat(firstDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-01T11:00Z");
assertThat(firstDriver.cardActivityIntervals()).hasSize(3);
assertThat(firstDriver.cardActivityIntervals().get(0).activityType()).isEqualTo("WORK");
assertThat(firstDriver.cardActivityIntervals().get(1).activityType()).isEqualTo("DRIVE");
assertThat(firstDriver.cardActivityIntervals().get(2).to().toString()).isEqualTo("2026-04-01T11:00:01Z");
assertThat(firstDriver.supportEvents()).hasSize(1);
assertThat(firstDriver.supportEvents().get(0).eventDomain()).isEqualTo("PLACE");
assertThat(firstDriver.supportEvents().get(0).eventType()).isEqualTo("BEGIN_DAILY_WORK_PERIOD");
assertThat(firstDriver.supportEvents().get(0).country()).isEqualTo("12");
assertThat(firstDriver.supportEvents().get(0).latitude()).isNotNull();
assertThat(secondDriver.cardVehicleUsageIntervals()).hasSize(1);
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-02T10:00Z");
assertThat(secondDriver.cardActivityIntervals()).hasSize(2);
assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z");
assertThat(secondDriver.cardActivityIntervals().get(1).to().toString()).isEqualTo("2026-04-02T10:00:01Z");
assertThat(secondDriver.supportEvents()).hasSize(2);
assertThat(secondDriver.supportEvents()).extracting("eventDomain").containsExactly("POSITION", "SPECIFIC_CONDITION");
assertThat(secondDriver.supportEvents().get(0).latitude()).isNotNull();
assertThat(secondDriver.supportEvents().get(1).code()).isEqualTo("1");
assertThat(session.warnings()).extracting("code")
.contains("OPEN_VU_CARD_INTERVAL", "VU_ACTIVITY_UNASSIGNED");
}
@Test
void extractsActivitiesWhenVuTimeOfChangeHasNoOffset() throws Exception {
TachographFileSession session = service.extract(
new TachographXmlParser.ParsedTachographXml(document(VehicleUnitXmlSamples.vehicleUnitXmlWithLocalActivityTimes()), "VehicleUnit"),
new TachographFileSessionMetadata(
"default",
"legalrequirements-vehicleunit",
"sample-vu",
"sample-vu.ddd",
"abc",
10,
"42",
"def",
false,
null
),
Instant.now(),
Instant.now().plus(4, ChronoUnit.HOURS)
);
DriverExtractionSession firstDriver = session.driversByKey().get("12:12345678901200");
DriverExtractionSession secondDriver = session.driversByKey().get("12:99999999999911");
assertThat(firstDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-01T08:00Z");
assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z");
}
private Document document(String xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
return factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)));
}
}

View File

@ -0,0 +1,184 @@
package at.procon.eventhub.tachographfilesession.service;
final class VehicleUnitXmlSamples {
private VehicleUnitXmlSamples() {
}
static String vehicleUnitXml() {
return """
<VehicleUnit>
<Overview>
<vehicleIdentificationNumber>VINVU123456789012</vehicleIdentificationNumber>
<vehicleRegistrationIdentification>
<vehicleRegistrationNation>12</vehicleRegistrationNation>
<vehicleRegistrationNumber><vehicleRegNumber>W-1000V</vehicleRegNumber></vehicleRegistrationNumber>
</vehicleRegistrationIdentification>
<vuDownloadablePeriod>
<minDownloadableTime>2026-04-01T00:00:00Z</minDownloadableTime>
<maxDownloadableTime>2026-04-02T10:00:00Z</maxDownloadableTime>
</vuDownloadablePeriod>
</Overview>
<Activities>
<vuCardIWData>
<vuCardIWRecords>
<cardHolderName>
<holderSurname><name>Muster</name></holderSurname>
<holderFirstNames><name>Max</name></holderFirstNames>
</cardHolderName>
<fullCardNumber>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>123456789012</driverIdentification>
<cardReplacementIndex>0</cardReplacementIndex>
<cardRenewalIndex>0</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</fullCardNumber>
<cardExpiryDate>2031-04-01T00:00:00Z</cardExpiryDate>
<cardInsertionTime>2026-04-01T08:00:00Z</cardInsertionTime>
<vehicleOdometerValueAtInsertion>1000</vehicleOdometerValueAtInsertion>
<cardSlotNumber>DRIVER</cardSlotNumber>
<cardWithdrawalTime>2026-04-01T11:00:00Z</cardWithdrawalTime>
<vehicleOdometerValueAtWithdrawal>1100</vehicleOdometerValueAtWithdrawal>
<manualInputFlag>NO_ENTRY</manualInputFlag>
</vuCardIWRecords>
<vuCardIWRecords>
<cardHolderName>
<holderSurname><name>Test</name></holderSurname>
<holderFirstNames><name>Tina</name></holderFirstNames>
</cardHolderName>
<fullCardNumber>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>999999999999</driverIdentification>
<cardReplacementIndex>1</cardReplacementIndex>
<cardRenewalIndex>1</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</fullCardNumber>
<cardInsertionTime>2026-04-02T07:30:00Z</cardInsertionTime>
<vehicleOdometerValueAtInsertion>1200</vehicleOdometerValueAtInsertion>
<cardSlotNumber>DRIVER</cardSlotNumber>
<manualInputFlag>MANUAL_ENTRIES</manualInputFlag>
</vuCardIWRecords>
</vuCardIWData>
<vuActivityDailyData>
<noOfActivityChanges>3</noOfActivityChanges>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>WORK</activity>
<timeOfChange>08:00:00Z</timeOfChange>
</activityChangeInfos>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>DRIVING</activity>
<timeOfChange>09:00:00Z</timeOfChange>
</activityChangeInfos>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>BREAK/REST</activity>
<timeOfChange>11:00:00Z</timeOfChange>
</activityChangeInfos>
</vuActivityDailyData>
</Activities>
<Activities>
<vuActivityDailyData>
<noOfActivityChanges>2</noOfActivityChanges>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>WORK</activity>
<timeOfChange>07:30:00Z</timeOfChange>
</activityChangeInfos>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>DRIVING</activity>
<timeOfChange>08:00:00Z</timeOfChange>
</activityChangeInfos>
</vuActivityDailyData>
<vuPlaceDailyWorkPeriodData>
<vuPlaceDailyWorkPeriodRecords>
<fullCardNumber>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>123456789012</driverIdentification>
<cardReplacementIndex>0</cardReplacementIndex>
<cardRenewalIndex>0</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</fullCardNumber>
<placeRecord>
<entryTime>2026-04-01T08:00:00Z</entryTime>
<entryTypeDailyWorkPeriod>0</entryTypeDailyWorkPeriod>
<dailyWorkPeriodCountry>12</dailyWorkPeriodCountry>
<dailyWorkPeriodRegion>9</dailyWorkPeriodRegion>
<vehicleOdometerValue>1000</vehicleOdometerValue>
<entryGnssPlaceRecord>
<timeStamp>2026-04-01T08:00:00Z</timeStamp>
<gnssAccuracy>5</gnssAccuracy>
<geoCoordinates>
<latitude>48.2082</latitude>
<longitude>16.3738</longitude>
</geoCoordinates>
<authenticationStatus>AUTHENTICATED</authenticationStatus>
</entryGnssPlaceRecord>
</placeRecord>
</vuPlaceDailyWorkPeriodRecords>
</vuPlaceDailyWorkPeriodData>
<vuGnssADData>
<vuGnssADRecord>
<timeStamp>2026-04-02T08:00:00Z</timeStamp>
<cardNumberDriverSlot>
<cardType>DRIVER_CARD</cardType>
<cardIssuingMemberState>12</cardIssuingMemberState>
<cardNumber>
<driverIdentification>999999999999</driverIdentification>
<cardReplacementIndex>1</cardReplacementIndex>
<cardRenewalIndex>1</cardRenewalIndex>
</cardNumber>
<generation>2</generation>
</cardNumberDriverSlot>
<gnssPlaceRecord>
<timeStamp>2026-04-02T08:00:00Z</timeStamp>
<gnssAccuracy>4</gnssAccuracy>
<geoCoordinates>
<latitude>48012</latitude>
<longitude>16373</longitude>
</geoCoordinates>
<authenticationStatus>NOT_AUTHENTICATED</authenticationStatus>
</gnssPlaceRecord>
<vehicleOdometerValue>1210</vehicleOdometerValue>
</vuGnssADRecord>
</vuGnssADData>
<vuSpecificConditionData>
<specificConditionRecords>
<entryTime>2026-04-02T09:00:00Z</entryTime>
<specificConditionType>1</specificConditionType>
</specificConditionRecords>
</vuSpecificConditionData>
</Activities>
</VehicleUnit>
""";
}
static String vehicleUnitXmlWithLocalActivityTimes() {
return vehicleUnitXml()
.replace("<timeOfChange>08:00:00Z</timeOfChange>", "<timeOfChange>08:00:00</timeOfChange>")
.replace("<timeOfChange>09:00:00Z</timeOfChange>", "<timeOfChange>09:00:00</timeOfChange>")
.replace("<timeOfChange>11:00:00Z</timeOfChange>", "<timeOfChange>11:00:00</timeOfChange>")
.replace("<timeOfChange>07:30:00Z</timeOfChange>", "<timeOfChange>07:30:00</timeOfChange>");
}
}