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