Add tachograph file session operating-period processing
This commit is contained in:
parent
4ad6fd7dac
commit
a20d4c241e
|
|
@ -351,6 +351,38 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Process tachograph file session operating periods",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"occurredFrom\": \"{{occurredFrom}}\",\n \"occurredTo\": \"{{occurredTo}}\",\n \"operatingSplitIdleHours\": 7,\n \"significantDrivingMinutes\": 3,\n \"mergeGapSeconds\": 0,\n \"gapDetectionToleranceSeconds\": 0\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}/drivers/{{driverKey}}/processing/operating-periods",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"eventhub",
|
||||||
|
"tachograph-file-sessions",
|
||||||
|
"{{sessionId}}",
|
||||||
|
"drivers",
|
||||||
|
"{{driverKey}}",
|
||||||
|
"processing",
|
||||||
|
"operating-periods"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Delete tachograph file session",
|
"name": "Delete tachograph file session",
|
||||||
"request": {
|
"request": {
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,7 @@ public class EventHubProperties {
|
||||||
private int maxSessions = 100;
|
private int maxSessions = 100;
|
||||||
private long maxFileSizeBytes = 20L * 1024L * 1024L;
|
private long maxFileSizeBytes = 20L * 1024L * 1024L;
|
||||||
private final LegalRequirements legalRequirements = new LegalRequirements();
|
private final LegalRequirements legalRequirements = new LegalRequirements();
|
||||||
|
private final Processing processing = new Processing();
|
||||||
|
|
||||||
public Duration getTtl() {
|
public Duration getTtl() {
|
||||||
return ttl;
|
return ttl;
|
||||||
|
|
@ -348,6 +349,49 @@ public class EventHubProperties {
|
||||||
public LegalRequirements getLegalRequirements() {
|
public LegalRequirements getLegalRequirements() {
|
||||||
return legalRequirements;
|
return legalRequirements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Processing getProcessing() {
|
||||||
|
return processing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Processing {
|
||||||
|
private int operatingSplitIdleHours = 7;
|
||||||
|
private int significantDrivingMinutes = 3;
|
||||||
|
private int mergeGapSeconds = 0;
|
||||||
|
private int gapDetectionToleranceSeconds = 0;
|
||||||
|
|
||||||
|
public int getOperatingSplitIdleHours() {
|
||||||
|
return operatingSplitIdleHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOperatingSplitIdleHours(int operatingSplitIdleHours) {
|
||||||
|
this.operatingSplitIdleHours = Math.max(1, operatingSplitIdleHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSignificantDrivingMinutes() {
|
||||||
|
return significantDrivingMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSignificantDrivingMinutes(int significantDrivingMinutes) {
|
||||||
|
this.significantDrivingMinutes = Math.max(1, significantDrivingMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMergeGapSeconds() {
|
||||||
|
return mergeGapSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMergeGapSeconds(int mergeGapSeconds) {
|
||||||
|
this.mergeGapSeconds = Math.max(0, mergeGapSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getGapDetectionToleranceSeconds() {
|
||||||
|
return gapDetectionToleranceSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGapDetectionToleranceSeconds(int gapDetectionToleranceSeconds) {
|
||||||
|
this.gapDetectionToleranceSeconds = Math.max(0, gapDetectionToleranceSeconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class LegalRequirements {
|
public static class LegalRequirements {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ package at.procon.eventhub.tachographfilesession.api;
|
||||||
|
|
||||||
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
|
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
|
||||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto;
|
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto;
|
||||||
|
import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest;
|
||||||
|
import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto;
|
||||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
|
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
|
||||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
|
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
|
||||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
|
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
|
||||||
|
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService;
|
||||||
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService;
|
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
|
@ -23,9 +27,14 @@ import org.springframework.web.multipart.MultipartFile;
|
||||||
public class TachographFileSessionController {
|
public class TachographFileSessionController {
|
||||||
|
|
||||||
private final TachographFileSessionService service;
|
private final TachographFileSessionService service;
|
||||||
|
private final TachographFileSessionProcessingService processingService;
|
||||||
|
|
||||||
public TachographFileSessionController(TachographFileSessionService service) {
|
public TachographFileSessionController(
|
||||||
|
TachographFileSessionService service,
|
||||||
|
TachographFileSessionProcessingService processingService
|
||||||
|
) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
|
this.processingService = processingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|
@ -57,6 +66,15 @@ public class TachographFileSessionController {
|
||||||
return ResponseEntity.ok(service.getDriver(sessionId, driverKey));
|
return ResponseEntity.ok(service.getDriver(sessionId, driverKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{sessionId}/drivers/{driverKey}/processing/operating-periods")
|
||||||
|
public ResponseEntity<TachographOperatingPeriodsProcessingResultDto> evaluateOperatingPeriods(
|
||||||
|
@PathVariable UUID sessionId,
|
||||||
|
@PathVariable String driverKey,
|
||||||
|
@RequestBody(required = false) TachographOperatingPeriodsProcessingRequest request
|
||||||
|
) {
|
||||||
|
return ResponseEntity.ok(processingService.evaluateOperatingPeriods(sessionId, driverKey, request));
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{sessionId}")
|
@DeleteMapping("/{sessionId}")
|
||||||
public ResponseEntity<TachographFileSessionDeleteResponse> deleteSession(@PathVariable UUID sessionId) {
|
public ResponseEntity<TachographFileSessionDeleteResponse> deleteSession(@PathVariable UUID sessionId) {
|
||||||
return ResponseEntity.ok(service.deleteSession(sessionId));
|
return ResponseEntity.ok(service.deleteSession(sessionId));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,21 +19,13 @@ import java.time.ZoneOffset;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.TreeSet;
|
import java.util.TreeSet;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import javax.xml.xpath.XPath;
|
|
||||||
import javax.xml.xpath.XPathConstants;
|
|
||||||
import javax.xml.xpath.XPathExpressionException;
|
|
||||||
import javax.xml.xpath.XPathFactory;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.w3c.dom.Document;
|
import org.w3c.dom.Document;
|
||||||
import org.w3c.dom.Element;
|
import org.w3c.dom.Element;
|
||||||
import org.w3c.dom.Node;
|
|
||||||
import org.w3c.dom.NodeList;
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
|
|
@ -41,6 +33,7 @@ public class DriverCardXmlExtractionService {
|
||||||
|
|
||||||
private final DriverKeyFactory driverKeyFactory;
|
private final DriverKeyFactory driverKeyFactory;
|
||||||
private final VehicleKeyFactory vehicleKeyFactory;
|
private final VehicleKeyFactory vehicleKeyFactory;
|
||||||
|
private final XmlExpressionEvaluator xml = new XmlExpressionEvaluator();
|
||||||
|
|
||||||
public DriverCardXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
|
public DriverCardXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
|
||||||
this.driverKeyFactory = driverKeyFactory;
|
this.driverKeyFactory = driverKeyFactory;
|
||||||
|
|
@ -276,41 +269,63 @@ public class DriverCardXmlExtractionService {
|
||||||
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals
|
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals
|
||||||
) {
|
) {
|
||||||
List<ExtractedCardActivityInterval> result = new ArrayList<>(activityIntervals.size());
|
List<ExtractedCardActivityInterval> result = new ArrayList<>(activityIntervals.size());
|
||||||
|
int usageStartIndex = 0;
|
||||||
for (ExtractedCardActivityInterval interval : activityIntervals) {
|
for (ExtractedCardActivityInterval interval : activityIntervals) {
|
||||||
result.addAll(splitByVehicleCoverage(interval, vehicleUsageIntervals));
|
while (usageStartIndex < vehicleUsageIntervals.size()
|
||||||
|
&& !endExclusive(vehicleUsageIntervals.get(usageStartIndex).to()).isAfter(interval.from())) {
|
||||||
|
usageStartIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
int overlapEndIndex = usageStartIndex;
|
||||||
|
while (overlapEndIndex < vehicleUsageIntervals.size()
|
||||||
|
&& vehicleUsageIntervals.get(overlapEndIndex).from().isBefore(interval.to())) {
|
||||||
|
overlapEndIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.addAll(splitByVehicleCoverage(
|
||||||
|
interval,
|
||||||
|
vehicleUsageIntervals.subList(usageStartIndex, overlapEndIndex)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ExtractedCardActivityInterval> splitByVehicleCoverage(
|
private List<ExtractedCardActivityInterval> splitByVehicleCoverage(
|
||||||
ExtractedCardActivityInterval interval,
|
ExtractedCardActivityInterval interval,
|
||||||
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals
|
List<ExtractedCardVehicleUsageInterval> overlappingUsages
|
||||||
) {
|
) {
|
||||||
Set<OffsetDateTime> cutPoints = new TreeSet<>();
|
TreeSet<OffsetDateTime> cutPoints = new TreeSet<>();
|
||||||
cutPoints.add(interval.from());
|
cutPoints.add(interval.from());
|
||||||
cutPoints.add(interval.to());
|
cutPoints.add(interval.to());
|
||||||
|
|
||||||
for (ExtractedCardVehicleUsageInterval usage : vehicleUsageIntervals) {
|
for (ExtractedCardVehicleUsageInterval usage : overlappingUsages) {
|
||||||
if (!usage.to().isBefore(interval.from()) && !usage.from().isAfter(interval.to())) {
|
if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) {
|
||||||
if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) {
|
cutPoints.add(usage.from());
|
||||||
cutPoints.add(usage.from());
|
}
|
||||||
}
|
OffsetDateTime usageEndExclusive = endExclusive(usage.to());
|
||||||
OffsetDateTime usageEndExclusive = usage.to().plusSeconds(1);
|
if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) {
|
||||||
if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) {
|
cutPoints.add(usageEndExclusive);
|
||||||
cutPoints.add(usageEndExclusive);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints);
|
List<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints);
|
||||||
List<ExtractedCardActivityInterval> segments = new ArrayList<>(Math.max(1, orderedCutPoints.size() - 1));
|
List<ExtractedCardActivityInterval> segments = new ArrayList<>(Math.max(1, orderedCutPoints.size() - 1));
|
||||||
|
int coverageIndex = 0;
|
||||||
for (int i = 0; i < orderedCutPoints.size() - 1; i++) {
|
for (int i = 0; i < orderedCutPoints.size() - 1; i++) {
|
||||||
OffsetDateTime segmentFrom = orderedCutPoints.get(i);
|
OffsetDateTime segmentFrom = orderedCutPoints.get(i);
|
||||||
OffsetDateTime segmentTo = orderedCutPoints.get(i + 1);
|
OffsetDateTime segmentTo = orderedCutPoints.get(i + 1);
|
||||||
if (!segmentFrom.isBefore(segmentTo)) {
|
if (!segmentFrom.isBefore(segmentTo)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ExtractedCardVehicleUsageInterval covering = findVehicleCoverage(segmentFrom, vehicleUsageIntervals);
|
|
||||||
|
while (coverageIndex < overlappingUsages.size()
|
||||||
|
&& !endExclusive(overlappingUsages.get(coverageIndex).to()).isAfter(segmentFrom)) {
|
||||||
|
coverageIndex++;
|
||||||
|
}
|
||||||
|
ExtractedCardVehicleUsageInterval covering = coverageIndex < overlappingUsages.size()
|
||||||
|
&& covers(overlappingUsages.get(coverageIndex), segmentFrom)
|
||||||
|
? overlappingUsages.get(coverageIndex)
|
||||||
|
: null;
|
||||||
String intervalId = orderedCutPoints.size() == 2 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1);
|
String intervalId = orderedCutPoints.size() == 2 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1);
|
||||||
segments.add(new ExtractedCardActivityInterval(
|
segments.add(new ExtractedCardActivityInterval(
|
||||||
intervalId,
|
intervalId,
|
||||||
|
|
@ -328,14 +343,12 @@ public class DriverCardXmlExtractionService {
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ExtractedCardVehicleUsageInterval findVehicleCoverage(
|
private boolean covers(ExtractedCardVehicleUsageInterval usage, OffsetDateTime timestamp) {
|
||||||
OffsetDateTime timestamp,
|
return !usage.from().isAfter(timestamp) && timestamp.isBefore(endExclusive(usage.to()));
|
||||||
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals
|
}
|
||||||
) {
|
|
||||||
return vehicleUsageIntervals.stream()
|
private OffsetDateTime endExclusive(OffsetDateTime timestamp) {
|
||||||
.filter(usage -> !usage.from().isAfter(timestamp) && timestamp.isBefore(usage.to().plusSeconds(1)))
|
return timestamp.plusSeconds(1);
|
||||||
.findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Element firstElement(Object node, String expression) {
|
private Element firstElement(Object node, String expression) {
|
||||||
|
|
@ -347,22 +360,11 @@ public class DriverCardXmlExtractionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private NodeList nodes(Object node, String expression) {
|
private NodeList nodes(Object node, String expression) {
|
||||||
try {
|
return xml.nodes(node, expression);
|
||||||
XPath xpath = XPathFactory.newInstance().newXPath();
|
|
||||||
return (NodeList) xpath.evaluate(expression, node, XPathConstants.NODESET);
|
|
||||||
} catch (XPathExpressionException e) {
|
|
||||||
throw new IllegalStateException("Invalid XPath expression: " + expression, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String text(Object node, String expression) {
|
private String text(Object node, String expression) {
|
||||||
try {
|
return xml.text(node, expression);
|
||||||
XPath xpath = XPathFactory.newInstance().newXPath();
|
|
||||||
String value = xpath.evaluate(expression, node);
|
|
||||||
return value == null || value.isBlank() ? null : value.trim();
|
|
||||||
} catch (XPathExpressionException e) {
|
|
||||||
throw new IllegalStateException("Invalid XPath expression: " + expression, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private OffsetDateTime offsetDateTime(String value) {
|
private OffsetDateTime offsetDateTime(String value) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,9 +60,9 @@ public class LegalRequirementsClient {
|
||||||
throw new LegalRequirementsUploadException("LegalRequirements upload failed with status " + response.statusCode());
|
throw new LegalRequirementsUploadException("LegalRequirements upload failed with status " + response.statusCode());
|
||||||
}
|
}
|
||||||
JsonNode root = objectMapper.readTree(response.body());
|
JsonNode root = objectMapper.readTree(response.body());
|
||||||
String dataPackageId = text(root, "DataPackageID");
|
String dataPackageId = text(root, "ID");
|
||||||
if (dataPackageId == null) {
|
if (dataPackageId == null) {
|
||||||
dataPackageId = text(root, "DataPackageId");
|
dataPackageId = text(root, "Id");
|
||||||
}
|
}
|
||||||
if (dataPackageId == null && root.has("value") && root.get("value").isObject()) {
|
if (dataPackageId == null && root.has("value") && root.get("value").isObject()) {
|
||||||
dataPackageId = text(root.get("value"), "DataPackageID");
|
dataPackageId = text(root.get("value"), "DataPackageID");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,8 @@ public class TachographXmlParser {
|
||||||
|
|
||||||
public ParsedTachographXml parse(String xmlContent) {
|
public ParsedTachographXml parse(String xmlContent) {
|
||||||
try {
|
try {
|
||||||
validate(xmlContent);
|
String normalizedXmlContent = normalizeXmlContent(xmlContent);
|
||||||
|
validate(normalizedXmlContent);
|
||||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||||
factory.setNamespaceAware(false);
|
factory.setNamespaceAware(false);
|
||||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||||
|
|
@ -42,7 +43,7 @@ public class TachographXmlParser {
|
||||||
factory.setXIncludeAware(false);
|
factory.setXIncludeAware(false);
|
||||||
factory.setExpandEntityReferences(false);
|
factory.setExpandEntityReferences(false);
|
||||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||||
Document document = builder.parse(new InputSource(new StringReader(xmlContent)));
|
Document document = builder.parse(new InputSource(new StringReader(normalizedXmlContent)));
|
||||||
String rootName = document.getDocumentElement() == null ? null : document.getDocumentElement().getNodeName();
|
String rootName = document.getDocumentElement() == null ? null : document.getDocumentElement().getNodeName();
|
||||||
if (!"DriverCard".equals(rootName) && !"VehicleUnit".equals(rootName)) {
|
if (!"DriverCard".equals(rootName) && !"VehicleUnit".equals(rootName)) {
|
||||||
throw new UnsupportedTachographFileTypeException("Only DriverCard and VehicleUnit XML documents are supported.");
|
throw new UnsupportedTachographFileTypeException("Only DriverCard and VehicleUnit XML documents are supported.");
|
||||||
|
|
@ -61,6 +62,13 @@ public class TachographXmlParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeXmlContent(String xmlContent) {
|
||||||
|
if (xmlContent == null || xmlContent.isEmpty()) {
|
||||||
|
return xmlContent;
|
||||||
|
}
|
||||||
|
return xmlContent.charAt(0) == '\uFEFF' ? xmlContent.substring(1) : xmlContent;
|
||||||
|
}
|
||||||
|
|
||||||
private Schema loadSchema() {
|
private Schema loadSchema() {
|
||||||
try {
|
try {
|
||||||
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
|
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,6 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.TreeSet;
|
import java.util.TreeSet;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import javax.xml.xpath.XPath;
|
|
||||||
import javax.xml.xpath.XPathConstants;
|
|
||||||
import javax.xml.xpath.XPathExpressionException;
|
|
||||||
import javax.xml.xpath.XPathFactory;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.w3c.dom.Document;
|
import org.w3c.dom.Document;
|
||||||
import org.w3c.dom.Element;
|
import org.w3c.dom.Element;
|
||||||
|
|
@ -39,6 +35,7 @@ public class VehicleUnitXmlExtractionService {
|
||||||
|
|
||||||
private final DriverKeyFactory driverKeyFactory;
|
private final DriverKeyFactory driverKeyFactory;
|
||||||
private final VehicleKeyFactory vehicleKeyFactory;
|
private final VehicleKeyFactory vehicleKeyFactory;
|
||||||
|
private final XmlExpressionEvaluator xml = new XmlExpressionEvaluator();
|
||||||
|
|
||||||
public VehicleUnitXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
|
public VehicleUnitXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
|
||||||
this.driverKeyFactory = driverKeyFactory;
|
this.driverKeyFactory = driverKeyFactory;
|
||||||
|
|
@ -688,22 +685,11 @@ public class VehicleUnitXmlExtractionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private NodeList nodes(Object node, String expression) {
|
private NodeList nodes(Object node, String expression) {
|
||||||
try {
|
return xml.nodes(node, expression);
|
||||||
XPath xpath = XPathFactory.newInstance().newXPath();
|
|
||||||
return (NodeList) xpath.evaluate(expression, node, XPathConstants.NODESET);
|
|
||||||
} catch (XPathExpressionException e) {
|
|
||||||
throw new IllegalStateException("Invalid XPath expression: " + expression, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String text(Object node, String expression) {
|
private String text(Object node, String expression) {
|
||||||
try {
|
return xml.text(node, expression);
|
||||||
XPath xpath = XPathFactory.newInstance().newXPath();
|
|
||||||
String value = xpath.evaluate(expression, node);
|
|
||||||
return value == null || value.isBlank() ? null : value.trim();
|
|
||||||
} catch (XPathExpressionException e) {
|
|
||||||
throw new IllegalStateException("Invalid XPath expression: " + expression, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private OffsetDateTime offsetDateTime(String value) {
|
private OffsetDateTime offsetDateTime(String value) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -125,6 +125,11 @@ eventhub:
|
||||||
ttl: 4h
|
ttl: 4h
|
||||||
max-sessions: 100
|
max-sessions: 100
|
||||||
max-file-size-bytes: 20971520
|
max-file-size-bytes: 20971520
|
||||||
|
processing:
|
||||||
|
operating-split-idle-hours: 7
|
||||||
|
significant-driving-minutes: 3
|
||||||
|
merge-gap-seconds: 0
|
||||||
|
gap-detection-tolerance-seconds: 0
|
||||||
legal-requirements:
|
legal-requirements:
|
||||||
base-url: ${LEGAL_REQUIREMENTS_BASE_URL:https://legalrequirements.services.bytebar.eu/ODataV4/LR}
|
base-url: ${LEGAL_REQUIREMENTS_BASE_URL:https://legalrequirements.services.bytebar.eu/ODataV4/LR}
|
||||||
username: ${LEGAL_REQUIREMENTS_USERNAME:}
|
username: ${LEGAL_REQUIREMENTS_USERNAME:}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||||
|
|
||||||
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
|
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
|
||||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto;
|
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto;
|
||||||
|
import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto;
|
||||||
|
import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest;
|
||||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto;
|
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto;
|
||||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
|
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
|
||||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
|
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
|
||||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
|
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
|
||||||
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
|
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
|
||||||
|
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService;
|
||||||
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService;
|
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -23,13 +26,15 @@ import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
|
||||||
class TachographFileSessionControllerTest {
|
class TachographFileSessionControllerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void uploadsSessionListsDriversAndDeletes() throws Exception {
|
void uploadsSessionListsDriversAndDeletes() throws Exception {
|
||||||
TachographFileSessionService service = org.mockito.Mockito.mock(TachographFileSessionService.class);
|
TachographFileSessionService service = org.mockito.Mockito.mock(TachographFileSessionService.class);
|
||||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TachographFileSessionController(service))
|
TachographFileSessionProcessingService processingService = org.mockito.Mockito.mock(TachographFileSessionProcessingService.class);
|
||||||
|
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TachographFileSessionController(service, processingService))
|
||||||
.setControllerAdvice(new TachographFileSessionExceptionHandler())
|
.setControllerAdvice(new TachographFileSessionExceptionHandler())
|
||||||
.build();
|
.build();
|
||||||
UUID sessionId = UUID.randomUUID();
|
UUID sessionId = UUID.randomUUID();
|
||||||
|
|
@ -53,6 +58,30 @@ class TachographFileSessionControllerTest {
|
||||||
when(service.getSession(sessionId)).thenReturn(summary);
|
when(service.getSession(sessionId)).thenReturn(summary);
|
||||||
when(service.listDrivers(sessionId)).thenReturn(new TachographFileSessionListDriversResponse(sessionId, List.of(driver)));
|
when(service.listDrivers(sessionId)).thenReturn(new TachographFileSessionListDriversResponse(sessionId, List.of(driver)));
|
||||||
when(service.getDriver(sessionId, "12:123")).thenReturn(new TachographFileDriverDetailDto(sessionId, "12:123", null, null, List.of(), List.of(), List.of(), List.of(), List.of(), List.of()));
|
when(service.getDriver(sessionId, "12:123")).thenReturn(new TachographFileDriverDetailDto(sessionId, "12:123", null, null, List.of(), List.of(), List.of(), List.of(), List.of(), List.of()));
|
||||||
|
when(processingService.evaluateOperatingPeriods(eq(sessionId), eq("12:123"), org.mockito.ArgumentMatchers.any(TachographOperatingPeriodsProcessingRequest.class)))
|
||||||
|
.thenReturn(new TachographOperatingPeriodsProcessingResultDto(
|
||||||
|
sessionId,
|
||||||
|
"12:123",
|
||||||
|
Instant.parse("2026-05-12T08:00:00Z").atOffset(java.time.ZoneOffset.UTC),
|
||||||
|
Instant.parse("2026-05-12T12:00:00Z").atOffset(java.time.ZoneOffset.UTC),
|
||||||
|
Instant.parse("2026-05-12T08:00:00Z").atOffset(java.time.ZoneOffset.UTC),
|
||||||
|
Instant.parse("2026-05-12T12:00:00Z").atOffset(java.time.ZoneOffset.UTC),
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
7,
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
List.of(),
|
||||||
|
List.of(),
|
||||||
|
List.of(),
|
||||||
|
List.of(),
|
||||||
|
List.of()
|
||||||
|
));
|
||||||
when(service.deleteSession(sessionId)).thenReturn(new TachographFileSessionDeleteResponse(sessionId, true));
|
when(service.deleteSession(sessionId)).thenReturn(new TachographFileSessionDeleteResponse(sessionId, true));
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/eventhub/tachograph-file-sessions")
|
mockMvc.perform(multipart("/api/eventhub/tachograph-file-sessions")
|
||||||
|
|
@ -75,6 +104,17 @@ class TachographFileSessionControllerTest {
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.driverKey").value("12:123"));
|
.andExpect(jsonPath("$.driverKey").value("12:123"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}/processing/operating-periods", sessionId, "12:123")
|
||||||
|
.contentType("application/json")
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"significantDrivingMinutes": 5
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.driverKey").value("12:123"))
|
||||||
|
.andExpect(jsonPath("$.operatingPeriodCount").value(1));
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId))
|
mockMvc.perform(delete("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.deleted").value(true));
|
.andExpect(jsonPath("$.deleted").value(true));
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,16 @@ class TachographXmlParserTest {
|
||||||
assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("VehicleUnit");
|
assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("VehicleUnit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesXmlWithLeadingUtf8BomCharacter() {
|
||||||
|
String xmlWithBom = "\uFEFF" + DriverCardXmlSamples.validDriverCardXml();
|
||||||
|
|
||||||
|
TachographXmlParser.ParsedTachographXml parsed = parser.parse(xmlWithBom);
|
||||||
|
|
||||||
|
assertThat(parsed.rootElementName()).isEqualTo("DriverCard");
|
||||||
|
assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("DriverCard");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsInvalidXmlAgainstSchema() {
|
void rejectsInvalidXmlAgainstSchema() {
|
||||||
String invalid = "<DriverCard><Identification></DriverCard>";
|
String invalid = "<DriverCard><Identification></DriverCard>";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue