Compare commits
8 Commits
d5a6254a33
...
7209a73d30
| Author | SHA1 | Date |
|---|---|---|
|
|
7209a73d30 | |
|
|
a20d4c241e | |
|
|
4ad6fd7dac | |
|
|
e30f98b2c0 | |
|
|
6b43a4b0e8 | |
|
|
39714b90b3 | |
|
|
c85b657acf | |
|
|
5a1061a314 |
|
|
@ -201,6 +201,208 @@
|
|||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tachograph file sessions",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create tachograph file session",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "{{tachographDddFile}}"
|
||||
},
|
||||
{
|
||||
"key": "tenantKey",
|
||||
"type": "text",
|
||||
"value": "{{tenantKey}}"
|
||||
},
|
||||
{
|
||||
"key": "sourceInstanceKey",
|
||||
"type": "text",
|
||||
"value": "{{sourceInstanceKey}}"
|
||||
},
|
||||
{
|
||||
"key": "sessionLabel",
|
||||
"type": "text",
|
||||
"value": "{{sessionLabel}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"eventhub",
|
||||
"tachograph-file-sessions"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Create tachograph vehicle unit file session",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "{{tachographVuDddFile}}"
|
||||
},
|
||||
{
|
||||
"key": "tenantKey",
|
||||
"type": "text",
|
||||
"value": "{{tenantKey}}"
|
||||
},
|
||||
{
|
||||
"key": "sourceInstanceKey",
|
||||
"type": "text",
|
||||
"value": "legalrequirements-vehicleunit"
|
||||
},
|
||||
{
|
||||
"key": "sessionLabel",
|
||||
"type": "text",
|
||||
"value": "vehicle-unit upload sample"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"eventhub",
|
||||
"tachograph-file-sessions"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get tachograph file session",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"eventhub",
|
||||
"tachograph-file-sessions",
|
||||
"{{sessionId}}"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "List tachograph file session drivers",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}/drivers",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"eventhub",
|
||||
"tachograph-file-sessions",
|
||||
"{{sessionId}}",
|
||||
"drivers"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get tachograph file session driver detail",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}/drivers/{{driverKey}}",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"eventhub",
|
||||
"tachograph-file-sessions",
|
||||
"{{sessionId}}",
|
||||
"drivers",
|
||||
"{{driverKey}}"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Process tachograph file session operating periods",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"occurredFrom\": \"{{occurredFrom}}\",\n \"occurredTo\": \"{{occurredTo}}\",\n \"operatingSplitIdleHours\": 7,\n \"significantDrivingMinutes\": 3,\n \"mergeGapSeconds\": 0,\n \"gapDetectionToleranceSeconds\": 0\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}/drivers/{{driverKey}}/processing/operating-periods",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"eventhub",
|
||||
"tachograph-file-sessions",
|
||||
"{{sessionId}}",
|
||||
"drivers",
|
||||
"{{driverKey}}",
|
||||
"processing",
|
||||
"operating-periods"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete tachograph file session",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"eventhub",
|
||||
"tachograph-file-sessions",
|
||||
"{{sessionId}}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
|
|
@ -227,6 +429,30 @@
|
|||
{
|
||||
"key": "occurredTo",
|
||||
"value": "2026-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"key": "sourceInstanceKey",
|
||||
"value": "legalrequirements-drivercard"
|
||||
},
|
||||
{
|
||||
"key": "sessionLabel",
|
||||
"value": "driver-card upload sample"
|
||||
},
|
||||
{
|
||||
"key": "sessionId",
|
||||
"value": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"key": "driverKey",
|
||||
"value": "12:12345678901200"
|
||||
},
|
||||
{
|
||||
"key": "tachographDddFile",
|
||||
"value": "C:\\\\temp\\\\driver-card.ddd"
|
||||
},
|
||||
{
|
||||
"key": "tachographVuDddFile",
|
||||
"value": "C:\\\\temp\\\\vehicle-unit.ddd"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public class EventHubProperties {
|
|||
|
||||
private final Batch batch = new Batch();
|
||||
private final Tachograph tachograph = new Tachograph();
|
||||
private final TachographFileSession tachographFileSession = new TachographFileSession();
|
||||
private final EsperPoc esperPoc = new EsperPoc();
|
||||
private final YellowFox yellowFox = new YellowFox();
|
||||
|
||||
|
|
@ -41,6 +42,10 @@ public class EventHubProperties {
|
|||
return tachograph;
|
||||
}
|
||||
|
||||
public TachographFileSession getTachographFileSession() {
|
||||
return tachographFileSession;
|
||||
}
|
||||
|
||||
public EsperPoc getEsperPoc() {
|
||||
return esperPoc;
|
||||
}
|
||||
|
|
@ -308,6 +313,166 @@ public class EventHubProperties {
|
|||
}
|
||||
}
|
||||
|
||||
public static class TachographFileSession {
|
||||
private Duration ttl = Duration.ofHours(4);
|
||||
private int maxSessions = 100;
|
||||
private long maxFileSizeBytes = 20L * 1024L * 1024L;
|
||||
private final LegalRequirements legalRequirements = new LegalRequirements();
|
||||
private final Processing processing = new Processing();
|
||||
|
||||
public Duration getTtl() {
|
||||
return ttl;
|
||||
}
|
||||
|
||||
public void setTtl(Duration ttl) {
|
||||
if (ttl != null && !ttl.isNegative() && !ttl.isZero()) {
|
||||
this.ttl = ttl;
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaxSessions() {
|
||||
return maxSessions;
|
||||
}
|
||||
|
||||
public void setMaxSessions(int maxSessions) {
|
||||
this.maxSessions = Math.max(1, maxSessions);
|
||||
}
|
||||
|
||||
public long getMaxFileSizeBytes() {
|
||||
return maxFileSizeBytes;
|
||||
}
|
||||
|
||||
public void setMaxFileSizeBytes(long maxFileSizeBytes) {
|
||||
this.maxFileSizeBytes = Math.max(1024L, maxFileSizeBytes);
|
||||
}
|
||||
|
||||
public LegalRequirements getLegalRequirements() {
|
||||
return legalRequirements;
|
||||
}
|
||||
|
||||
public Processing getProcessing() {
|
||||
return processing;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Processing {
|
||||
private int operatingSplitIdleHours = 7;
|
||||
private int significantDrivingMinutes = 3;
|
||||
private int mergeGapSeconds = 0;
|
||||
private int gapDetectionToleranceSeconds = 0;
|
||||
|
||||
public int getOperatingSplitIdleHours() {
|
||||
return operatingSplitIdleHours;
|
||||
}
|
||||
|
||||
public void setOperatingSplitIdleHours(int operatingSplitIdleHours) {
|
||||
this.operatingSplitIdleHours = Math.max(1, operatingSplitIdleHours);
|
||||
}
|
||||
|
||||
public int getSignificantDrivingMinutes() {
|
||||
return significantDrivingMinutes;
|
||||
}
|
||||
|
||||
public void setSignificantDrivingMinutes(int significantDrivingMinutes) {
|
||||
this.significantDrivingMinutes = Math.max(1, significantDrivingMinutes);
|
||||
}
|
||||
|
||||
public int getMergeGapSeconds() {
|
||||
return mergeGapSeconds;
|
||||
}
|
||||
|
||||
public void setMergeGapSeconds(int mergeGapSeconds) {
|
||||
this.mergeGapSeconds = Math.max(0, mergeGapSeconds);
|
||||
}
|
||||
|
||||
public int getGapDetectionToleranceSeconds() {
|
||||
return gapDetectionToleranceSeconds;
|
||||
}
|
||||
|
||||
public void setGapDetectionToleranceSeconds(int gapDetectionToleranceSeconds) {
|
||||
this.gapDetectionToleranceSeconds = Math.max(0, gapDetectionToleranceSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public static class LegalRequirements {
|
||||
private static final String DEFAULT_BASE_URL = "https://legalrequirements.services.bytebar.eu/ODataV4/LR";
|
||||
|
||||
private String baseUrl = DEFAULT_BASE_URL;
|
||||
private String username;
|
||||
private String password;
|
||||
private Duration connectTimeout = Duration.ofSeconds(20);
|
||||
private Duration readTimeout = Duration.ofMinutes(2);
|
||||
private boolean resetSessionAfterUse = false;
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = normalizeBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public Duration getConnectTimeout() {
|
||||
return connectTimeout;
|
||||
}
|
||||
|
||||
public void setConnectTimeout(Duration connectTimeout) {
|
||||
if (connectTimeout != null && !connectTimeout.isNegative() && !connectTimeout.isZero()) {
|
||||
this.connectTimeout = connectTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
public Duration getReadTimeout() {
|
||||
return readTimeout;
|
||||
}
|
||||
|
||||
public void setReadTimeout(Duration readTimeout) {
|
||||
if (readTimeout != null && !readTimeout.isNegative() && !readTimeout.isZero()) {
|
||||
this.readTimeout = readTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isResetSessionAfterUse() {
|
||||
return resetSessionAfterUse;
|
||||
}
|
||||
|
||||
public void setResetSessionAfterUse(boolean resetSessionAfterUse) {
|
||||
this.resetSessionAfterUse = resetSessionAfterUse;
|
||||
}
|
||||
|
||||
private String normalizeBaseUrl(String baseUrl) {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
return DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
String normalized = baseUrl.trim();
|
||||
while (normalized.startsWith(":")) {
|
||||
normalized = normalized.substring(1).trim();
|
||||
}
|
||||
while (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1).trim();
|
||||
}
|
||||
|
||||
return normalized.isBlank() ? DEFAULT_BASE_URL : normalized;
|
||||
}
|
||||
}
|
||||
|
||||
public static class TachographDataSource {
|
||||
private String jdbcUrl;
|
||||
private String username;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
package at.procon.eventhub.tachographfilesession.api;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
|
||||
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService;
|
||||
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/eventhub/tachograph-file-sessions")
|
||||
public class TachographFileSessionController {
|
||||
|
||||
private final TachographFileSessionService service;
|
||||
private final TachographFileSessionProcessingService processingService;
|
||||
|
||||
public TachographFileSessionController(
|
||||
TachographFileSessionService service,
|
||||
TachographFileSessionProcessingService processingService
|
||||
) {
|
||||
this.service = service;
|
||||
this.processingService = processingService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CreateTachographFileSessionResponse> createSession(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(required = false) String tenantKey,
|
||||
@RequestParam(required = false) String sourceInstanceKey,
|
||||
@RequestParam(required = false) String sessionLabel
|
||||
) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(service.createSession(file, tenantKey, sourceInstanceKey, sessionLabel));
|
||||
}
|
||||
|
||||
@GetMapping("/{sessionId}")
|
||||
public ResponseEntity<TachographFileSessionSummaryDto> getSession(@PathVariable UUID sessionId) {
|
||||
return ResponseEntity.ok(service.getSession(sessionId));
|
||||
}
|
||||
|
||||
@GetMapping("/{sessionId}/drivers")
|
||||
public ResponseEntity<TachographFileSessionListDriversResponse> listDrivers(@PathVariable UUID sessionId) {
|
||||
return ResponseEntity.ok(service.listDrivers(sessionId));
|
||||
}
|
||||
|
||||
@GetMapping("/{sessionId}/drivers/{driverKey}")
|
||||
public ResponseEntity<TachographFileDriverDetailDto> getDriver(
|
||||
@PathVariable UUID sessionId,
|
||||
@PathVariable String driverKey
|
||||
) {
|
||||
return ResponseEntity.ok(service.getDriver(sessionId, driverKey));
|
||||
}
|
||||
|
||||
@PostMapping("/{sessionId}/drivers/{driverKey}/processing/operating-periods")
|
||||
public ResponseEntity<TachographOperatingPeriodsProcessingResultDto> evaluateOperatingPeriods(
|
||||
@PathVariable UUID sessionId,
|
||||
@PathVariable String driverKey,
|
||||
@RequestBody(required = false) TachographOperatingPeriodsProcessingRequest request
|
||||
) {
|
||||
return ResponseEntity.ok(processingService.evaluateOperatingPeriods(sessionId, driverKey, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{sessionId}")
|
||||
public ResponseEntity<TachographFileSessionDeleteResponse> deleteSession(@PathVariable UUID sessionId) {
|
||||
return ResponseEntity.ok(service.deleteSession(sessionId));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package at.procon.eventhub.tachographfilesession.api;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.service.DriverNotFoundInSessionException;
|
||||
import at.procon.eventhub.tachographfilesession.service.LegalRequirementsUploadException;
|
||||
import at.procon.eventhub.tachographfilesession.service.LegalRequirementsXmlDownloadException;
|
||||
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionNotFoundException;
|
||||
import at.procon.eventhub.tachographfilesession.service.TachographXmlValidationException;
|
||||
import at.procon.eventhub.tachographfilesession.service.UnsupportedTachographFileTypeException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice(basePackageClasses = TachographFileSessionController.class)
|
||||
public class TachographFileSessionExceptionHandler {
|
||||
|
||||
@ExceptionHandler({
|
||||
TachographFileSessionNotFoundException.class,
|
||||
DriverNotFoundInSessionException.class
|
||||
})
|
||||
public ResponseEntity<Map<String, Object>> notFound(RuntimeException exception) {
|
||||
return error(HttpStatus.NOT_FOUND, exception);
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
IllegalArgumentException.class,
|
||||
TachographXmlValidationException.class,
|
||||
UnsupportedTachographFileTypeException.class
|
||||
})
|
||||
public ResponseEntity<Map<String, Object>> badRequest(RuntimeException exception) {
|
||||
return error(HttpStatus.BAD_REQUEST, exception);
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
LegalRequirementsUploadException.class,
|
||||
LegalRequirementsXmlDownloadException.class
|
||||
})
|
||||
public ResponseEntity<Map<String, Object>> badGateway(RuntimeException exception) {
|
||||
return error(HttpStatus.BAD_GATEWAY, exception);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> error(HttpStatus status, RuntimeException exception) {
|
||||
return ResponseEntity.status(status).body(Map.of(
|
||||
"timestamp", OffsetDateTime.now().toString(),
|
||||
"status", status.value(),
|
||||
"error", status.getReasonPhrase(),
|
||||
"message", exception.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package at.procon.eventhub.tachographfilesession.dto;
|
||||
|
||||
public record CreateTachographFileSessionResponse(
|
||||
TachographFileSessionSummaryDto session
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package at.procon.eventhub.tachographfilesession.dto;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedDriver;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record TachographFileDriverDetailDto(
|
||||
UUID sessionId,
|
||||
String driverKey,
|
||||
ExtractedDriver driver,
|
||||
ExtractedDriverCard driverCard,
|
||||
List<ExtractedVehicleRegistration> vehicleRegistrations,
|
||||
List<ExtractedVehicle> vehicles,
|
||||
List<ExtractedCardVehicleUsageInterval> cardVehicleUsageIntervals,
|
||||
List<ExtractedCardActivityInterval> cardActivityIntervals,
|
||||
List<ExtractedSupportEvent> supportEvents,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package at.procon.eventhub.tachographfilesession.dto;
|
||||
|
||||
public record TachographFileDriverSummaryDto(
|
||||
String driverKey,
|
||||
String surname,
|
||||
String firstNames,
|
||||
String cardNation,
|
||||
String cardNumber,
|
||||
int activityIntervalCount,
|
||||
int cardVehicleUsageIntervalCount
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package at.procon.eventhub.tachographfilesession.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record TachographFileSessionDeleteResponse(
|
||||
UUID sessionId,
|
||||
boolean deleted
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package at.procon.eventhub.tachographfilesession.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record TachographFileSessionListDriversResponse(
|
||||
UUID sessionId,
|
||||
List<TachographFileDriverSummaryDto> drivers
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package at.procon.eventhub.tachographfilesession.dto;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record TachographFileSessionSummaryDto(
|
||||
UUID sessionId,
|
||||
String tenantKey,
|
||||
String sourceInstanceKey,
|
||||
String sessionLabel,
|
||||
String originalFileName,
|
||||
boolean driverCardFile,
|
||||
String legalRequirementsDataPackageId,
|
||||
ExtractionStats stats,
|
||||
List<TachographFileDriverSummaryDto> drivers,
|
||||
List<ExtractionWarning> warnings,
|
||||
Instant createdAt,
|
||||
Instant expiresAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -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,16 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record DriverExtractionSession(
|
||||
String driverKey,
|
||||
ExtractedDriver driver,
|
||||
ExtractedDriverCard driverCard,
|
||||
List<ExtractedVehicleRegistration> vehicleRegistrations,
|
||||
List<ExtractedVehicle> vehicles,
|
||||
List<ExtractedCardVehicleUsageInterval> cardVehicleUsageIntervals,
|
||||
List<ExtractedCardActivityInterval> cardActivityIntervals,
|
||||
List<ExtractedSupportEvent> supportEvents,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record ExtractedCardActivityInterval(
|
||||
String intervalId,
|
||||
OffsetDateTime from,
|
||||
OffsetDateTime to,
|
||||
String activityType,
|
||||
String slot,
|
||||
String cardStatus,
|
||||
String drivingStatus,
|
||||
String registrationKey,
|
||||
String vehicleKey,
|
||||
String rawRecordPath
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record ExtractedCardVehicleUsageInterval(
|
||||
String intervalId,
|
||||
OffsetDateTime from,
|
||||
OffsetDateTime to,
|
||||
Long odometerBeginKm,
|
||||
Long odometerEndKm,
|
||||
String registrationKey,
|
||||
String vehicleKey,
|
||||
String rawRecordPath
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record ExtractedDriver(
|
||||
String driverKey,
|
||||
String sourceDriverId,
|
||||
String surname,
|
||||
String firstNames,
|
||||
LocalDate birthDate,
|
||||
String preferredLanguage,
|
||||
String drivingLicenceNumber,
|
||||
String drivingLicenceNation,
|
||||
String drivingLicenceAuthority
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record ExtractedDriverCard(
|
||||
String sourceDriverCardId,
|
||||
String cardNation,
|
||||
String cardNumber,
|
||||
String issuingAuthorityName,
|
||||
OffsetDateTime issueDate,
|
||||
OffsetDateTime validityBegin,
|
||||
OffsetDateTime expiryDate
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record ExtractedSupportEvent(
|
||||
String eventId,
|
||||
OffsetDateTime occurredAt,
|
||||
String eventDomain,
|
||||
String eventType,
|
||||
String slot,
|
||||
String registrationKey,
|
||||
String vehicleKey,
|
||||
String country,
|
||||
String region,
|
||||
BigDecimal latitude,
|
||||
BigDecimal longitude,
|
||||
String authenticationStatus,
|
||||
Long odometerKm,
|
||||
String code,
|
||||
String rawRecordPath
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
public record ExtractedVehicle(
|
||||
String vehicleKey,
|
||||
String sourceVehicleId,
|
||||
String vin
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
public record ExtractedVehicleRegistration(
|
||||
String registrationKey,
|
||||
String sourceVehicleRegistrationId,
|
||||
String registrationNation,
|
||||
String registrationNumber
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
public record ExtractionStats(
|
||||
int driverCount,
|
||||
int activityIntervalCount,
|
||||
int cardVehicleUsageIntervalCount,
|
||||
int vehicleRegistrationCount,
|
||||
int vehicleCount,
|
||||
int warningCount
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
public record ExtractionWarning(
|
||||
String code,
|
||||
String message,
|
||||
String rawRecordPath
|
||||
) {
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record TachographFileSession(
|
||||
UUID sessionId,
|
||||
TachographFileSessionMetadata metadata,
|
||||
Map<String, DriverExtractionSession> driversByKey,
|
||||
ExtractionStats extractionStats,
|
||||
java.util.List<ExtractionWarning> warnings,
|
||||
Instant createdAt,
|
||||
Instant expiresAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package at.procon.eventhub.tachographfilesession.model;
|
||||
|
||||
public record TachographFileSessionMetadata(
|
||||
String tenantKey,
|
||||
String sourceInstanceKey,
|
||||
String sessionLabel,
|
||||
String originalFileName,
|
||||
String uploadedFileSha256,
|
||||
long uploadedFileSize,
|
||||
String legalRequirementsDataPackageId,
|
||||
String xmlSha256,
|
||||
boolean driverCardFile,
|
||||
String xmlGeneration
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,479 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedDriver;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
@Component
|
||||
public class DriverCardXmlExtractionService {
|
||||
|
||||
private final DriverKeyFactory driverKeyFactory;
|
||||
private final VehicleKeyFactory vehicleKeyFactory;
|
||||
|
||||
public DriverCardXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
|
||||
this.driverKeyFactory = driverKeyFactory;
|
||||
this.vehicleKeyFactory = vehicleKeyFactory;
|
||||
}
|
||||
|
||||
public TachographFileSession extract(
|
||||
TachographXmlParser.ParsedTachographXml parsedXml,
|
||||
TachographFileSessionMetadata metadata,
|
||||
Instant createdAt,
|
||||
Instant expiresAt
|
||||
) {
|
||||
Document document = parsedXml.document();
|
||||
List<ExtractionWarning> warnings = new ArrayList<>();
|
||||
|
||||
ExtractedDriverCard driverCard = extractDriverCard(document, warnings);
|
||||
if (driverCard == null || driverCard.cardNumber() == null) {
|
||||
throw new TachographXmlValidationException("Driver card identification could not be extracted from XML.");
|
||||
}
|
||||
String driverKey = driverKeyFactory.createDriverKey(driverCard.cardNation(), driverCard.cardNumber());
|
||||
driverCard = new ExtractedDriverCard(
|
||||
driverKeyFactory.createSourceDriverCardId(driverKey),
|
||||
driverCard.cardNation(),
|
||||
driverCard.cardNumber(),
|
||||
driverCard.issuingAuthorityName(),
|
||||
driverCard.issueDate(),
|
||||
driverCard.validityBegin(),
|
||||
driverCard.expiryDate()
|
||||
);
|
||||
ExtractedDriver driver = extractDriver(document, driverKey, warnings);
|
||||
|
||||
LinkedHashMap<String, ExtractedVehicleRegistration> registrationsByKey = new LinkedHashMap<>();
|
||||
LinkedHashMap<String, ExtractedVehicle> vehiclesByKey = new LinkedHashMap<>();
|
||||
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals =
|
||||
extractVehicleUsageIntervals(document, registrationsByKey, vehiclesByKey, warnings);
|
||||
List<ExtractedCardActivityInterval> activityIntervals =
|
||||
assignVehicleCoverage(extractActivityIntervals(document, warnings), vehicleUsageIntervals);
|
||||
|
||||
DriverExtractionSession driverSession = new DriverExtractionSession(
|
||||
driverKey,
|
||||
driver,
|
||||
driverCard,
|
||||
List.copyOf(registrationsByKey.values()),
|
||||
List.copyOf(vehiclesByKey.values()),
|
||||
List.copyOf(vehicleUsageIntervals),
|
||||
List.copyOf(activityIntervals),
|
||||
List.of(),
|
||||
List.copyOf(warnings)
|
||||
);
|
||||
Map<String, DriverExtractionSession> driversByKey = Map.of(driverKey, driverSession);
|
||||
ExtractionStats stats = new ExtractionStats(
|
||||
1,
|
||||
activityIntervals.size(),
|
||||
vehicleUsageIntervals.size(),
|
||||
registrationsByKey.size(),
|
||||
vehiclesByKey.size(),
|
||||
warnings.size()
|
||||
);
|
||||
return new TachographFileSession(
|
||||
UUID.randomUUID(),
|
||||
metadata,
|
||||
driversByKey,
|
||||
stats,
|
||||
List.copyOf(warnings),
|
||||
createdAt,
|
||||
expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
private ExtractedDriverCard extractDriverCard(Document document, List<ExtractionWarning> warnings) {
|
||||
Element identification = child(document.getDocumentElement(), "Identification");
|
||||
if (identification == null) {
|
||||
warnings.add(new ExtractionWarning("MISSING_IDENTIFICATION", "Driver card identification block is missing.", "/DriverCard"));
|
||||
return null;
|
||||
}
|
||||
Element cardIdentification = child(identification, "cardIdentification");
|
||||
String cardNation = childText(cardIdentification, "cardIssuingMemberState");
|
||||
String cardNumber = joinCardNumber(identification);
|
||||
String authority = childText(child(cardIdentification, "cardIssuingAuthorityName"), "name");
|
||||
return new ExtractedDriverCard(
|
||||
null,
|
||||
cardNation,
|
||||
cardNumber,
|
||||
authority,
|
||||
offsetDateTime(childText(cardIdentification, "cardIssueDate")),
|
||||
offsetDateTime(childText(cardIdentification, "cardValidityBegin")),
|
||||
offsetDateTime(childText(cardIdentification, "cardExpiryDate"))
|
||||
);
|
||||
}
|
||||
|
||||
private ExtractedDriver extractDriver(Document document, String driverKey, List<ExtractionWarning> warnings) {
|
||||
Element identification = child(document.getDocumentElement(), "Identification");
|
||||
if (identification == null) {
|
||||
warnings.add(new ExtractionWarning("MISSING_DRIVER", "Driver holder identification block is missing.", "/DriverCard"));
|
||||
return new ExtractedDriver(
|
||||
driverKey,
|
||||
driverKeyFactory.createSourceDriverId(driverKey),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
Element driverCardHolderIdentification = child(identification, "driverCardHolderIdentification");
|
||||
Element cardHolderName = child(driverCardHolderIdentification, "cardHolderName");
|
||||
Element licenceInfo = child(document.getDocumentElement(), "DrivingLicenseInfo");
|
||||
Element cardDrivingLicenseInformation = child(licenceInfo, "cardDrivingLicenseInformation");
|
||||
return new ExtractedDriver(
|
||||
driverKey,
|
||||
driverKeyFactory.createSourceDriverId(driverKey),
|
||||
childText(child(cardHolderName, "holderSurname"), "name"),
|
||||
childText(child(cardHolderName, "holderFirstNames"), "name"),
|
||||
localDate(child(driverCardHolderIdentification, "cardHolderBirthDate")),
|
||||
childText(driverCardHolderIdentification, "cardHolderPreferredLanguage"),
|
||||
childText(cardDrivingLicenseInformation, "drivingLicenceNumber"),
|
||||
childText(cardDrivingLicenseInformation, "drivingLicenceIssuingNation"),
|
||||
childText(child(cardDrivingLicenseInformation, "drivingLicenceIssuingAuthority"), "name")
|
||||
);
|
||||
}
|
||||
|
||||
private List<ExtractedCardVehicleUsageInterval> extractVehicleUsageIntervals(
|
||||
Document document,
|
||||
Map<String, ExtractedVehicleRegistration> registrationsByKey,
|
||||
Map<String, ExtractedVehicle> vehiclesByKey,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
Element cardVehiclesUsed = child(child(document.getDocumentElement(), "VehiclesUsed"), "cardVehiclesUsed");
|
||||
List<Element> records = children(cardVehiclesUsed, "cardVehicleRecords");
|
||||
List<ExtractedCardVehicleUsageInterval> intervals = new ArrayList<>(records.size());
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
Element record = records.get(i);
|
||||
String path = "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords[" + (i + 1) + "]";
|
||||
OffsetDateTime from = offsetDateTime(childText(record, "vehicleFirstUse"));
|
||||
OffsetDateTime to = offsetDateTime(childText(record, "vehicleLastUse"));
|
||||
Element vehicleRegistration = child(record, "vehicleRegistration");
|
||||
String registrationNation = childText(vehicleRegistration, "vehicleRegistrationNation");
|
||||
String registrationNumber = childText(child(vehicleRegistration, "vehicleRegistrationNumber"), "vehicleRegNumber");
|
||||
String registrationKey = vehicleKeyFactory.createRegistrationKey(registrationNation, registrationNumber);
|
||||
registrationsByKey.putIfAbsent(
|
||||
registrationKey,
|
||||
new ExtractedVehicleRegistration(
|
||||
registrationKey,
|
||||
vehicleKeyFactory.createSourceVehicleRegistrationId(registrationKey),
|
||||
registrationNation,
|
||||
registrationNumber
|
||||
)
|
||||
);
|
||||
String vin = childText(record, "vehicleIdentificationNumber");
|
||||
String vehicleKey = vehicleKeyFactory.createVehicleKey(vin);
|
||||
if (vehicleKey != null) {
|
||||
vehiclesByKey.putIfAbsent(
|
||||
vehicleKey,
|
||||
new ExtractedVehicle(vehicleKey, vehicleKeyFactory.createSourceVehicleId(vehicleKey), vin)
|
||||
);
|
||||
}
|
||||
if (from == null || to == null) {
|
||||
warnings.add(new ExtractionWarning("INCOMPLETE_VEHICLE_USAGE", "Vehicle usage interval is missing start or end timestamp.", path));
|
||||
continue;
|
||||
}
|
||||
intervals.add(new ExtractedCardVehicleUsageInterval(
|
||||
"CVU-" + (i + 1),
|
||||
from,
|
||||
to,
|
||||
longValue(childText(record, "vehicleOdometerBegin")),
|
||||
longValue(childText(record, "vehicleOdometerEnd")),
|
||||
registrationKey,
|
||||
vehicleKey,
|
||||
path
|
||||
));
|
||||
}
|
||||
intervals.sort(Comparator.comparing(ExtractedCardVehicleUsageInterval::from));
|
||||
return intervals;
|
||||
}
|
||||
|
||||
private List<ExtractedCardActivityInterval> extractActivityIntervals(Document document, List<ExtractionWarning> warnings) {
|
||||
Element cardDriverActivity = child(child(document.getDocumentElement(), "DriverActivityData"), "cardDriverActivity");
|
||||
List<Element> dayRecords = children(cardDriverActivity, "cardActivityDailyRecord");
|
||||
List<ExtractedCardActivityInterval> intervals = new ArrayList<>(dayRecords.size() * 8);
|
||||
int intervalNo = 0;
|
||||
for (int dayIndex = 0; dayIndex < dayRecords.size(); dayIndex++) {
|
||||
Element dayRecord = dayRecords.get(dayIndex);
|
||||
String dayPath = "/DriverCard/DriverActivityData/cardDriverActivity/cardActivityDailyRecord[" + (dayIndex + 1) + "]";
|
||||
OffsetDateTime recordDate = offsetDateTime(childText(dayRecord, "activityRecordDate"));
|
||||
if (recordDate == null) {
|
||||
warnings.add(new ExtractionWarning("MISSING_ACTIVITY_RECORD_DATE", "Activity daily record has no activityRecordDate.", dayPath));
|
||||
continue;
|
||||
}
|
||||
LocalDate date = recordDate.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate();
|
||||
List<Element> changes = children(dayRecord, "activityChangeInfos");
|
||||
List<ActivityChange> parsedChanges = new ArrayList<>(changes.size());
|
||||
for (int changeIndex = 0; changeIndex < changes.size(); changeIndex++) {
|
||||
Element change = changes.get(changeIndex);
|
||||
OffsetDateTime from = combine(date, childText(change, "timeOfChange"));
|
||||
if (from == null) {
|
||||
warnings.add(new ExtractionWarning("INVALID_ACTIVITY_CHANGE_TIME", "Activity change has invalid timeOfChange.", dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"));
|
||||
continue;
|
||||
}
|
||||
parsedChanges.add(new ActivityChange(
|
||||
from,
|
||||
normalizeActivity(childText(change, "activity")),
|
||||
normalizeToken(childText(change, "slot")),
|
||||
normalizeToken(childText(change, "cardStatus")),
|
||||
normalizeToken(childText(change, "drivingStatus")),
|
||||
dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
|
||||
));
|
||||
}
|
||||
parsedChanges.sort(Comparator.comparing(ActivityChange::from));
|
||||
for (int i = 0; i < parsedChanges.size(); i++) {
|
||||
ActivityChange current = parsedChanges.get(i);
|
||||
OffsetDateTime to = i + 1 < parsedChanges.size()
|
||||
? parsedChanges.get(i + 1).from()
|
||||
: date.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC);
|
||||
if (!current.from().isBefore(to)) {
|
||||
continue;
|
||||
}
|
||||
intervalNo++;
|
||||
intervals.add(new ExtractedCardActivityInterval(
|
||||
"ACT-" + intervalNo,
|
||||
current.from(),
|
||||
to,
|
||||
current.activityType(),
|
||||
current.slot(),
|
||||
current.cardStatus(),
|
||||
current.drivingStatus(),
|
||||
null,
|
||||
null,
|
||||
current.rawRecordPath()
|
||||
));
|
||||
}
|
||||
}
|
||||
intervals.sort(Comparator.comparing(ExtractedCardActivityInterval::from));
|
||||
return intervals;
|
||||
}
|
||||
|
||||
private List<ExtractedCardActivityInterval> assignVehicleCoverage(
|
||||
List<ExtractedCardActivityInterval> activityIntervals,
|
||||
List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals
|
||||
) {
|
||||
List<ExtractedCardActivityInterval> result = new ArrayList<>(activityIntervals.size());
|
||||
int usageStartIndex = 0;
|
||||
for (ExtractedCardActivityInterval interval : activityIntervals) {
|
||||
while (usageStartIndex < vehicleUsageIntervals.size()
|
||||
&& !endExclusive(vehicleUsageIntervals.get(usageStartIndex).to()).isAfter(interval.from())) {
|
||||
usageStartIndex++;
|
||||
}
|
||||
|
||||
int overlapEndIndex = usageStartIndex;
|
||||
while (overlapEndIndex < vehicleUsageIntervals.size()
|
||||
&& vehicleUsageIntervals.get(overlapEndIndex).from().isBefore(interval.to())) {
|
||||
overlapEndIndex++;
|
||||
}
|
||||
|
||||
result.addAll(splitByVehicleCoverage(
|
||||
interval,
|
||||
vehicleUsageIntervals.subList(usageStartIndex, overlapEndIndex)
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ExtractedCardActivityInterval> splitByVehicleCoverage(
|
||||
ExtractedCardActivityInterval interval,
|
||||
List<ExtractedCardVehicleUsageInterval> overlappingUsages
|
||||
) {
|
||||
TreeSet<OffsetDateTime> cutPoints = new TreeSet<>();
|
||||
cutPoints.add(interval.from());
|
||||
cutPoints.add(interval.to());
|
||||
|
||||
for (ExtractedCardVehicleUsageInterval usage : overlappingUsages) {
|
||||
if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) {
|
||||
cutPoints.add(usage.from());
|
||||
}
|
||||
OffsetDateTime usageEndExclusive = endExclusive(usage.to());
|
||||
if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) {
|
||||
cutPoints.add(usageEndExclusive);
|
||||
}
|
||||
}
|
||||
|
||||
List<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints);
|
||||
List<ExtractedCardActivityInterval> segments = new ArrayList<>(Math.max(1, orderedCutPoints.size() - 1));
|
||||
int coverageIndex = 0;
|
||||
for (int i = 0; i < orderedCutPoints.size() - 1; i++) {
|
||||
OffsetDateTime segmentFrom = orderedCutPoints.get(i);
|
||||
OffsetDateTime segmentTo = orderedCutPoints.get(i + 1);
|
||||
if (!segmentFrom.isBefore(segmentTo)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
while (coverageIndex < overlappingUsages.size()
|
||||
&& !endExclusive(overlappingUsages.get(coverageIndex).to()).isAfter(segmentFrom)) {
|
||||
coverageIndex++;
|
||||
}
|
||||
ExtractedCardVehicleUsageInterval covering = coverageIndex < overlappingUsages.size()
|
||||
&& covers(overlappingUsages.get(coverageIndex), segmentFrom)
|
||||
? overlappingUsages.get(coverageIndex)
|
||||
: null;
|
||||
String intervalId = orderedCutPoints.size() == 2 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1);
|
||||
segments.add(new ExtractedCardActivityInterval(
|
||||
intervalId,
|
||||
segmentFrom,
|
||||
segmentTo,
|
||||
interval.activityType(),
|
||||
interval.slot(),
|
||||
interval.cardStatus(),
|
||||
interval.drivingStatus(),
|
||||
covering == null ? null : covering.registrationKey(),
|
||||
covering == null ? null : covering.vehicleKey(),
|
||||
interval.rawRecordPath()
|
||||
));
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
private boolean covers(ExtractedCardVehicleUsageInterval usage, OffsetDateTime timestamp) {
|
||||
return !usage.from().isAfter(timestamp) && timestamp.isBefore(endExclusive(usage.to()));
|
||||
}
|
||||
|
||||
private OffsetDateTime endExclusive(OffsetDateTime timestamp) {
|
||||
return timestamp.plusSeconds(1);
|
||||
}
|
||||
|
||||
private Element child(Element parent, String name) {
|
||||
if (parent == null) {
|
||||
return null;
|
||||
}
|
||||
NodeList childNodes = parent.getChildNodes();
|
||||
for (int i = 0; i < childNodes.getLength(); i++) {
|
||||
Node child = childNodes.item(i);
|
||||
if (child.getNodeType() == Node.ELEMENT_NODE && name.equals(child.getNodeName())) {
|
||||
return (Element) child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<Element> children(Element parent, String name) {
|
||||
if (parent == null) {
|
||||
return List.of();
|
||||
}
|
||||
NodeList childNodes = parent.getChildNodes();
|
||||
List<Element> children = new ArrayList<>();
|
||||
for (int i = 0; i < childNodes.getLength(); i++) {
|
||||
Node child = childNodes.item(i);
|
||||
if (child.getNodeType() == Node.ELEMENT_NODE && name.equals(child.getNodeName())) {
|
||||
children.add((Element) child);
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
private String childText(Element parent, String name) {
|
||||
Element child = child(parent, name);
|
||||
if (child == null) {
|
||||
return null;
|
||||
}
|
||||
String value = child.getTextContent();
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private OffsetDateTime offsetDateTime(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return OffsetDateTime.parse(value.trim()).withOffsetSameInstant(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
private LocalDate localDate(Element dateElement) {
|
||||
if (dateElement == null) {
|
||||
return null;
|
||||
}
|
||||
String year = childText(dateElement, "year");
|
||||
String month = childText(dateElement, "month");
|
||||
String day = childText(dateElement, "day");
|
||||
if (year == null || month == null || day == null) {
|
||||
return null;
|
||||
}
|
||||
return LocalDate.of(Integer.parseInt(year), Integer.parseInt(month), Integer.parseInt(day));
|
||||
}
|
||||
|
||||
private OffsetDateTime combine(LocalDate date, String timeText) {
|
||||
return TachographTimeParser.combineUtc(date, timeText);
|
||||
}
|
||||
|
||||
private Long longValue(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Long.parseLong(value.trim());
|
||||
}
|
||||
|
||||
private String normalizeActivity(String value) {
|
||||
String normalized = normalizeToken(value);
|
||||
if (normalized == null) {
|
||||
return "UNKNOWN_ACTIVITY";
|
||||
}
|
||||
return switch (normalized) {
|
||||
case "DRIVING", "DRIVE" -> "DRIVE";
|
||||
case "WORK" -> "WORK";
|
||||
case "AVAILABILITY", "AVAILABLE" -> "AVAILABILITY";
|
||||
case "BREAK_REST", "BREAK/REST", "REST" -> "BREAK_REST";
|
||||
default -> "UNKNOWN_ACTIVITY";
|
||||
};
|
||||
}
|
||||
|
||||
private String normalizeToken(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim().toUpperCase().replace('-', '_').replace(' ', '_');
|
||||
}
|
||||
|
||||
private record ActivityChange(
|
||||
OffsetDateTime from,
|
||||
String activityType,
|
||||
String slot,
|
||||
String cardStatus,
|
||||
String drivingStatus,
|
||||
String rawRecordPath
|
||||
) {
|
||||
}
|
||||
|
||||
private String joinCardNumber(Element identification) {
|
||||
Element cardIdentification = child(identification, "cardIdentification");
|
||||
Element cardNumber = child(cardIdentification, "cardNumber");
|
||||
String driverIdentification = childText(cardNumber, "driverIdentification");
|
||||
if (driverIdentification == null) {
|
||||
return null;
|
||||
}
|
||||
String replacement = childText(cardNumber, "cardReplacementIndex");
|
||||
String renewal = childText(cardNumber, "cardRenewalIndex");
|
||||
StringBuilder builder = new StringBuilder(driverIdentification);
|
||||
if (replacement != null) {
|
||||
builder.append(replacement);
|
||||
}
|
||||
if (renewal != null) {
|
||||
builder.append(renewal);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class DriverKeyFactory {
|
||||
|
||||
public String createDriverKey(String cardNation, String cardNumber) {
|
||||
String normalizedNation = normalize(cardNation, "UNKNOWN");
|
||||
String normalizedCardNumber = normalize(cardNumber, "UNKNOWN");
|
||||
return normalizedNation + ":" + normalizedCardNumber;
|
||||
}
|
||||
|
||||
public String createSourceDriverId(String driverKey) {
|
||||
return "DRV:" + driverKey;
|
||||
}
|
||||
|
||||
public String createSourceDriverCardId(String driverKey) {
|
||||
return "CARD:" + driverKey;
|
||||
}
|
||||
|
||||
private String normalize(String value, String fallback) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class DriverNotFoundInSessionException extends RuntimeException {
|
||||
|
||||
public DriverNotFoundInSessionException(UUID sessionId, String driverKey) {
|
||||
super("Driver not found in session " + sessionId + ": " + driverKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import at.procon.eventhub.config.EventHubProperties;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class InMemoryTachographFileSessionRepository implements TachographFileSessionRepository {
|
||||
|
||||
private final ConcurrentHashMap<UUID, TachographFileSession> sessions = new ConcurrentHashMap<>();
|
||||
private final EventHubProperties properties;
|
||||
|
||||
public InMemoryTachographFileSessionRepository(EventHubProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TachographFileSession save(TachographFileSession session) {
|
||||
cleanupExpired();
|
||||
if (!sessions.containsKey(session.sessionId())
|
||||
&& sessions.size() >= properties.getTachographFileSession().getMaxSessions()) {
|
||||
throw new IllegalStateException("Maximum number of tachograph file sessions reached.");
|
||||
}
|
||||
sessions.put(session.sessionId(), session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TachographFileSession> find(UUID sessionId) {
|
||||
cleanupExpired();
|
||||
return Optional.ofNullable(sessions.get(sessionId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(UUID sessionId) {
|
||||
cleanupExpired();
|
||||
return sessions.remove(sessionId) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TachographFileSession> list() {
|
||||
cleanupExpired();
|
||||
return List.copyOf(sessions.values());
|
||||
}
|
||||
|
||||
private void cleanupExpired() {
|
||||
Instant now = Instant.now();
|
||||
sessions.entrySet().removeIf(entry -> entry.getValue().expiresAt() != null && entry.getValue().expiresAt().isBefore(now));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import at.procon.eventhub.config.EventHubProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.net.CookieManager;
|
||||
import java.net.CookiePolicy;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class LegalRequirementsClient {
|
||||
|
||||
private final EventHubProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public LegalRequirementsClient(EventHubProperties properties, ObjectMapper objectMapper) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public LegalRequirementsUploadResult uploadTachographFile(byte[] fileBytes, String fileName) {
|
||||
EventHubProperties.LegalRequirements config = properties.getTachographFileSession().getLegalRequirements();
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(config.getConnectTimeout())
|
||||
.cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_ALL))
|
||||
.build();
|
||||
try {
|
||||
String dataPackageId = uploadDataPackage(client, config, fileBytes, fileName);
|
||||
String xml = downloadXml(client, config, dataPackageId);
|
||||
return new LegalRequirementsUploadResult(dataPackageId, xml);
|
||||
} finally {
|
||||
if (config.isResetSessionAfterUse()) {
|
||||
resetSessionQuietly(client, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String uploadDataPackage(HttpClient client, EventHubProperties.LegalRequirements config, byte[] fileBytes, String fileName) {
|
||||
try {
|
||||
String payload = objectMapper.createObjectNode()
|
||||
.put("FileName", fileName)
|
||||
.putNull("CompressionMediaType")
|
||||
.put("Data", Base64.getEncoder().encodeToString(fileBytes))
|
||||
.toString();
|
||||
HttpRequest request = requestBuilder(config.getBaseUrl() + "/DataPackages/Upload", config.getReadTimeout())
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", basicAuth(config))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new LegalRequirementsUploadException("LegalRequirements upload failed with status " + response.statusCode());
|
||||
}
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
String dataPackageId = text(root, "ID");
|
||||
if (dataPackageId == null) {
|
||||
dataPackageId = text(root, "Id");
|
||||
}
|
||||
if (dataPackageId == null && root.has("value") && root.get("value").isObject()) {
|
||||
dataPackageId = text(root.get("value"), "DataPackageID");
|
||||
}
|
||||
if (dataPackageId == null) {
|
||||
throw new LegalRequirementsUploadException("LegalRequirements upload response did not contain DataPackageID.");
|
||||
}
|
||||
return dataPackageId;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new LegalRequirementsUploadException("Failed to upload tachograph file to LegalRequirements.", e);
|
||||
} catch (IOException e) {
|
||||
throw new LegalRequirementsUploadException("Failed to upload tachograph file to LegalRequirements.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String downloadXml(HttpClient client, EventHubProperties.LegalRequirements config, String dataPackageId) {
|
||||
try {
|
||||
HttpRequest request = requestBuilder(config.getBaseUrl() + "/DataPackages/" + dataPackageId + "/Xml", config.getReadTimeout())
|
||||
.header("Authorization", basicAuth(config))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new LegalRequirementsXmlDownloadException("LegalRequirements XML download failed with status " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new LegalRequirementsXmlDownloadException("Failed to download processed tachograph XML.", e);
|
||||
} catch (IOException e) {
|
||||
throw new LegalRequirementsXmlDownloadException("Failed to download processed tachograph XML.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void resetSessionQuietly(HttpClient client, EventHubProperties.LegalRequirements config) {
|
||||
try {
|
||||
HttpRequest request = requestBuilder(config.getBaseUrl() + "/LegalRequirementsResults/ResetSession", config.getReadTimeout())
|
||||
.header("Authorization", basicAuth(config))
|
||||
.POST(HttpRequest.BodyPublishers.noBody())
|
||||
.build();
|
||||
client.send(request, HttpResponse.BodyHandlers.discarding());
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequest.Builder requestBuilder(String url, Duration timeout) {
|
||||
return HttpRequest.newBuilder(URI.create(url)).timeout(timeout);
|
||||
}
|
||||
|
||||
private String basicAuth(EventHubProperties.LegalRequirements config) {
|
||||
String user = config.getUsername() == null ? "" : config.getUsername();
|
||||
String password = config.getPassword() == null ? "" : config.getPassword();
|
||||
String token = Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8));
|
||||
return "Basic " + token;
|
||||
}
|
||||
|
||||
private String text(JsonNode node, String field) {
|
||||
if (node == null || !node.has(field) || node.get(field).isNull()) {
|
||||
return null;
|
||||
}
|
||||
return node.get(field).asText();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
public class LegalRequirementsUploadException extends RuntimeException {
|
||||
|
||||
public LegalRequirementsUploadException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public LegalRequirementsUploadException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
public record LegalRequirementsUploadResult(
|
||||
String dataPackageId,
|
||||
String xmlContent
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
public class LegalRequirementsXmlDownloadException extends RuntimeException {
|
||||
|
||||
public LegalRequirementsXmlDownloadException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public LegalRequirementsXmlDownloadException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class TachographFileSessionNotFoundException extends RuntimeException {
|
||||
|
||||
public TachographFileSessionNotFoundException(UUID sessionId) {
|
||||
super("Tachograph file session not found: " + sessionId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TachographFileSessionRepository {
|
||||
|
||||
TachographFileSession save(TachographFileSession session);
|
||||
|
||||
Optional<TachographFileSession> find(UUID sessionId);
|
||||
|
||||
boolean delete(UUID sessionId);
|
||||
|
||||
List<TachographFileSession> list();
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import at.procon.eventhub.config.EventHubProperties;
|
||||
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
|
||||
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
public class TachographFileSessionService {
|
||||
|
||||
private final EventHubProperties properties;
|
||||
private final TachographFileSessionRepository repository;
|
||||
private final LegalRequirementsClient legalRequirementsClient;
|
||||
private final TachographXmlParser tachographXmlParser;
|
||||
private final DriverCardXmlExtractionService driverCardExtractionService;
|
||||
private final VehicleUnitXmlExtractionService vehicleUnitExtractionService;
|
||||
|
||||
public TachographFileSessionService(
|
||||
EventHubProperties properties,
|
||||
TachographFileSessionRepository repository,
|
||||
LegalRequirementsClient legalRequirementsClient,
|
||||
TachographXmlParser tachographXmlParser,
|
||||
DriverCardXmlExtractionService driverCardExtractionService,
|
||||
VehicleUnitXmlExtractionService vehicleUnitExtractionService
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.repository = repository;
|
||||
this.legalRequirementsClient = legalRequirementsClient;
|
||||
this.tachographXmlParser = tachographXmlParser;
|
||||
this.driverCardExtractionService = driverCardExtractionService;
|
||||
this.vehicleUnitExtractionService = vehicleUnitExtractionService;
|
||||
}
|
||||
|
||||
public CreateTachographFileSessionResponse createSession(
|
||||
MultipartFile file,
|
||||
String tenantKey,
|
||||
String sourceInstanceKey,
|
||||
String sessionLabel
|
||||
) {
|
||||
try {
|
||||
validateFile(file);
|
||||
byte[] fileBytes = file.getBytes();
|
||||
validateFileBytes(fileBytes);
|
||||
LegalRequirementsUploadResult uploadResult = legalRequirementsClient.uploadTachographFile(fileBytes, file.getOriginalFilename());
|
||||
TachographXmlParser.ParsedTachographXml parsedXml = tachographXmlParser.parse(uploadResult.xmlContent());
|
||||
Instant createdAt = Instant.now();
|
||||
Instant expiresAt = createdAt.plus(properties.getTachographFileSession().getTtl());
|
||||
boolean driverCardFile = "DriverCard".equals(parsedXml.rootElementName());
|
||||
TachographFileSessionMetadata metadata = new TachographFileSessionMetadata(
|
||||
tenant(tenantKey),
|
||||
sourceInstance(sourceInstanceKey, driverCardFile),
|
||||
blankToNull(sessionLabel),
|
||||
file.getOriginalFilename(),
|
||||
sha256(fileBytes),
|
||||
fileBytes.length,
|
||||
uploadResult.dataPackageId(),
|
||||
sha256(uploadResult.xmlContent().getBytes(StandardCharsets.UTF_8)),
|
||||
driverCardFile,
|
||||
null
|
||||
);
|
||||
TachographFileSession session = driverCardFile
|
||||
? driverCardExtractionService.extract(parsedXml, metadata, createdAt, expiresAt)
|
||||
: vehicleUnitExtractionService.extract(parsedXml, metadata, createdAt, expiresAt);
|
||||
TachographFileSession saved = repository.save(session);
|
||||
return new CreateTachographFileSessionResponse(toSummary(saved));
|
||||
} catch (Exception e) {
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
throw new IllegalStateException("Failed to create tachograph file session.", e);
|
||||
}
|
||||
}
|
||||
|
||||
public TachographFileSessionSummaryDto getSession(UUID sessionId) {
|
||||
return toSummary(requireSession(sessionId));
|
||||
}
|
||||
|
||||
public TachographFileSessionListDriversResponse listDrivers(UUID sessionId) {
|
||||
TachographFileSession session = requireSession(sessionId);
|
||||
return new TachographFileSessionListDriversResponse(session.sessionId(), driverSummaries(session));
|
||||
}
|
||||
|
||||
public TachographFileDriverDetailDto getDriver(UUID sessionId, String driverKey) {
|
||||
TachographFileSession session = requireSession(sessionId);
|
||||
DriverExtractionSession driver = session.driversByKey().get(driverKey);
|
||||
if (driver == null) {
|
||||
throw new DriverNotFoundInSessionException(sessionId, driverKey);
|
||||
}
|
||||
return new TachographFileDriverDetailDto(
|
||||
session.sessionId(),
|
||||
driver.driverKey(),
|
||||
driver.driver(),
|
||||
driver.driverCard(),
|
||||
driver.vehicleRegistrations(),
|
||||
driver.vehicles(),
|
||||
driver.cardVehicleUsageIntervals(),
|
||||
driver.cardActivityIntervals(),
|
||||
driver.supportEvents(),
|
||||
driver.warnings()
|
||||
);
|
||||
}
|
||||
|
||||
public TachographFileSessionDeleteResponse deleteSession(UUID sessionId) {
|
||||
return new TachographFileSessionDeleteResponse(sessionId, repository.delete(sessionId));
|
||||
}
|
||||
|
||||
private TachographFileSession requireSession(UUID sessionId) {
|
||||
return repository.find(sessionId).orElseThrow(() -> new TachographFileSessionNotFoundException(sessionId));
|
||||
}
|
||||
|
||||
private TachographFileSessionSummaryDto toSummary(TachographFileSession session) {
|
||||
return new TachographFileSessionSummaryDto(
|
||||
session.sessionId(),
|
||||
session.metadata().tenantKey(),
|
||||
session.metadata().sourceInstanceKey(),
|
||||
session.metadata().sessionLabel(),
|
||||
session.metadata().originalFileName(),
|
||||
session.metadata().driverCardFile(),
|
||||
session.metadata().legalRequirementsDataPackageId(),
|
||||
session.extractionStats(),
|
||||
driverSummaries(session),
|
||||
session.warnings(),
|
||||
session.createdAt(),
|
||||
session.expiresAt()
|
||||
);
|
||||
}
|
||||
|
||||
private List<TachographFileDriverSummaryDto> driverSummaries(TachographFileSession session) {
|
||||
return session.driversByKey().values().stream()
|
||||
.sorted(Comparator.comparing(DriverExtractionSession::driverKey))
|
||||
.map(driver -> new TachographFileDriverSummaryDto(
|
||||
driver.driverKey(),
|
||||
driver.driver() == null ? null : driver.driver().surname(),
|
||||
driver.driver() == null ? null : driver.driver().firstNames(),
|
||||
driver.driverCard() == null ? null : driver.driverCard().cardNation(),
|
||||
driver.driverCard() == null ? null : driver.driverCard().cardNumber(),
|
||||
driver.cardActivityIntervals().size(),
|
||||
driver.cardVehicleUsageIntervals().size()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void validateFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty() || file.getSize() == 0L) {
|
||||
throw new IllegalArgumentException("Tachograph file must not be empty.");
|
||||
}
|
||||
if (file.getSize() > properties.getTachographFileSession().getMaxFileSizeBytes()) {
|
||||
throw new IllegalArgumentException("Tachograph file exceeds the configured size limit.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateFileBytes(byte[] fileBytes) {
|
||||
if (fileBytes == null || fileBytes.length == 0) {
|
||||
throw new IllegalArgumentException("Tachograph file must not be empty.");
|
||||
}
|
||||
if (fileBytes.length > properties.getTachographFileSession().getMaxFileSizeBytes()) {
|
||||
throw new IllegalArgumentException("Tachograph file exceeds the configured size limit.");
|
||||
}
|
||||
}
|
||||
|
||||
private String tenant(String tenantKey) {
|
||||
return blankToNull(tenantKey) == null ? "default" : tenantKey.trim();
|
||||
}
|
||||
|
||||
private String sourceInstance(String sourceInstanceKey, boolean driverCardFile) {
|
||||
if (blankToNull(sourceInstanceKey) != null) {
|
||||
return sourceInstanceKey.trim();
|
||||
}
|
||||
return driverCardFile ? "legalrequirements-drivercard" : "legalrequirements-vehicleunit";
|
||||
}
|
||||
|
||||
private String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private String sha256(byte[] bytes) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 digest is not available.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.OffsetTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
final class TachographTimeParser {
|
||||
|
||||
private TachographTimeParser() {
|
||||
}
|
||||
|
||||
static OffsetDateTime combineUtc(LocalDate date, String timeText) {
|
||||
if (date == null || timeText == null || timeText.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalized = timeText.trim();
|
||||
try {
|
||||
OffsetTime offsetTime = OffsetTime.parse(normalized);
|
||||
return date.atTime(offsetTime.toLocalTime())
|
||||
.atOffset(offsetTime.getOffset())
|
||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
return date.atTime(LocalTime.parse(normalized)).atOffset(ZoneOffset.UTC);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import javax.xml.transform.stream.StreamSource;
|
||||
import javax.xml.validation.Schema;
|
||||
import javax.xml.validation.SchemaFactory;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.xml.sax.InputSource;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
@Component
|
||||
public class TachographXmlParser {
|
||||
|
||||
private static final String JAXP_MAX_OCCUR_LIMIT = "http://www.oracle.com/xml/jaxp/properties/maxOccurLimit";
|
||||
|
||||
private final Schema schema;
|
||||
|
||||
public TachographXmlParser() {
|
||||
this.schema = loadSchema();
|
||||
}
|
||||
|
||||
public ParsedTachographXml parse(String xmlContent) {
|
||||
try {
|
||||
String normalizedXmlContent = normalizeXmlContent(xmlContent);
|
||||
validate(normalizedXmlContent);
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(false);
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document document = builder.parse(new InputSource(new StringReader(normalizedXmlContent)));
|
||||
String rootName = document.getDocumentElement() == null ? null : document.getDocumentElement().getNodeName();
|
||||
if (!"DriverCard".equals(rootName) && !"VehicleUnit".equals(rootName)) {
|
||||
throw new UnsupportedTachographFileTypeException("Only DriverCard and VehicleUnit XML documents are supported.");
|
||||
}
|
||||
return new ParsedTachographXml(document, rootName);
|
||||
} catch (ParserConfigurationException | IOException | SAXException e) {
|
||||
throw new TachographXmlValidationException("Failed to parse tachograph XML.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(String xmlContent) {
|
||||
try {
|
||||
schema.newValidator().validate(new StreamSource(new StringReader(xmlContent)));
|
||||
} catch (IOException | SAXException e) {
|
||||
throw new TachographXmlValidationException("Tachograph XML failed XSD validation.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeXmlContent(String xmlContent) {
|
||||
if (xmlContent == null || xmlContent.isEmpty()) {
|
||||
return xmlContent;
|
||||
}
|
||||
return xmlContent.charAt(0) == '\uFEFF' ? xmlContent.substring(1) : xmlContent;
|
||||
}
|
||||
|
||||
private Schema loadSchema() {
|
||||
try {
|
||||
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
|
||||
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||
factory.setProperty(JAXP_MAX_OCCUR_LIMIT, 100_000);
|
||||
byte[] content = new ClassPathResource("xsd/Tachograph.xsd").getContentAsByteArray();
|
||||
return factory.newSchema(new StreamSource(new ByteArrayInputStream(content)));
|
||||
} catch (IOException | SAXException e) {
|
||||
throw new IllegalStateException("Failed to load Tachograph XSD schema.", e);
|
||||
}
|
||||
}
|
||||
|
||||
public record ParsedTachographXml(
|
||||
Document document,
|
||||
String rootElementName
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
public class TachographXmlValidationException extends RuntimeException {
|
||||
|
||||
public TachographXmlValidationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public TachographXmlValidationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
public class UnsupportedTachographFileTypeException extends RuntimeException {
|
||||
|
||||
public UnsupportedTachographFileTypeException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class VehicleKeyFactory {
|
||||
|
||||
public String createRegistrationKey(String registrationNation, String registrationNumber) {
|
||||
String normalizedNation = normalize(registrationNation, "UNKNOWN");
|
||||
String normalizedNumber = normalize(registrationNumber, "UNKNOWN");
|
||||
return normalizedNation + ":" + normalizedNumber;
|
||||
}
|
||||
|
||||
public String createSourceVehicleRegistrationId(String registrationKey) {
|
||||
return "VR:" + registrationKey;
|
||||
}
|
||||
|
||||
public String createVehicleKey(String vin) {
|
||||
return normalize(vin, null);
|
||||
}
|
||||
|
||||
public String createSourceVehicleId(String vehicleKey) {
|
||||
return vehicleKey == null ? null : "VIN:" + vehicleKey;
|
||||
}
|
||||
|
||||
private String normalize(String value, String fallback) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,925 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedDriver;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
@Component
|
||||
public class VehicleUnitXmlExtractionService {
|
||||
|
||||
private final DriverKeyFactory driverKeyFactory;
|
||||
private final VehicleKeyFactory vehicleKeyFactory;
|
||||
private final XmlExpressionEvaluator xml = new XmlExpressionEvaluator();
|
||||
|
||||
public VehicleUnitXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
|
||||
this.driverKeyFactory = driverKeyFactory;
|
||||
this.vehicleKeyFactory = vehicleKeyFactory;
|
||||
}
|
||||
|
||||
public TachographFileSession extract(
|
||||
TachographXmlParser.ParsedTachographXml parsedXml,
|
||||
TachographFileSessionMetadata metadata,
|
||||
Instant createdAt,
|
||||
Instant expiresAt
|
||||
) {
|
||||
Document document = parsedXml.document();
|
||||
List<ExtractionWarning> sessionWarnings = new ArrayList<>();
|
||||
|
||||
VehicleContext vehicleContext = extractVehicleContext(document, sessionWarnings);
|
||||
Map<String, DriverExtractionBuilder> driversByKey = new LinkedHashMap<>();
|
||||
List<VuCardIwInterval> vuCardIwIntervals = new ArrayList<>();
|
||||
|
||||
NodeList records = nodes(document, "/VehicleUnit/Activities/vuCardIWData/vuCardIWRecords");
|
||||
for (int i = 0; i < records.getLength(); i++) {
|
||||
Element record = (Element) records.item(i);
|
||||
String path = "/VehicleUnit/Activities/vuCardIWData/vuCardIWRecords[" + (i + 1) + "]";
|
||||
String driverIdentification = text(record, "fullCardNumber/cardNumber/driverIdentification");
|
||||
if (driverIdentification == null) {
|
||||
continue;
|
||||
}
|
||||
String cardNation = text(record, "fullCardNumber/cardIssuingMemberState");
|
||||
String cardNumber = joinCardNumber(record, "fullCardNumber/cardNumber");
|
||||
if (cardNumber == null) {
|
||||
sessionWarnings.add(new ExtractionWarning(
|
||||
"MISSING_VU_DRIVER_CARD",
|
||||
"Vehicle-unit insertion/withdrawal record is missing a usable driver card number.",
|
||||
path
|
||||
));
|
||||
continue;
|
||||
}
|
||||
String driverKey = driverKeyFactory.createDriverKey(cardNation, cardNumber);
|
||||
DriverExtractionBuilder builder = driversByKey.computeIfAbsent(
|
||||
driverKey,
|
||||
ignored -> new DriverExtractionBuilder(driverKey, driverKeyFactory.createSourceDriverId(driverKey))
|
||||
);
|
||||
builder.mergeDriver(
|
||||
text(record, "cardHolderName/holderSurname/name"),
|
||||
text(record, "cardHolderName/holderFirstNames/name")
|
||||
);
|
||||
builder.mergeDriverCard(
|
||||
driverKeyFactory.createSourceDriverCardId(driverKey),
|
||||
cardNation,
|
||||
cardNumber,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
offsetDateTime(text(record, "cardExpiryDate"))
|
||||
);
|
||||
builder.addVehicleContext(vehicleContext.registration(), vehicleContext.vehicle());
|
||||
|
||||
OffsetDateTime from = offsetDateTime(text(record, "cardInsertionTime"));
|
||||
if (from == null) {
|
||||
sessionWarnings.add(new ExtractionWarning(
|
||||
"MISSING_VU_CARD_INSERTION",
|
||||
"Vehicle-unit insertion/withdrawal record is missing cardInsertionTime.",
|
||||
path
|
||||
));
|
||||
continue;
|
||||
}
|
||||
OffsetDateTime to = offsetDateTime(text(record, "cardWithdrawalTime"));
|
||||
if (to == null) {
|
||||
to = vehicleContext.defaultOpenIntervalEnd();
|
||||
sessionWarnings.add(new ExtractionWarning(
|
||||
"OPEN_VU_CARD_INTERVAL",
|
||||
"Vehicle-unit insertion/withdrawal record has no withdrawal time; interval was closed using the VU downloadable-period end.",
|
||||
path
|
||||
));
|
||||
}
|
||||
if (to == null || to.isBefore(from)) {
|
||||
sessionWarnings.add(new ExtractionWarning(
|
||||
"INVALID_VU_CARD_INTERVAL",
|
||||
"Vehicle-unit insertion/withdrawal record has an invalid interval range.",
|
||||
path
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.vehicleUsageIntervals.add(new ExtractedCardVehicleUsageInterval(
|
||||
"VUIW-" + (i + 1),
|
||||
from,
|
||||
to,
|
||||
longValue(text(record, "vehicleOdometerValueAtInsertion")),
|
||||
longValue(text(record, "vehicleOdometerValueAtWithdrawal")),
|
||||
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
|
||||
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
|
||||
path
|
||||
));
|
||||
vuCardIwIntervals.add(new VuCardIwInterval(
|
||||
driverKey,
|
||||
normalizeToken(text(record, "cardSlotNumber")),
|
||||
from,
|
||||
to,
|
||||
path
|
||||
));
|
||||
}
|
||||
|
||||
if (driversByKey.isEmpty()) {
|
||||
sessionWarnings.add(new ExtractionWarning(
|
||||
"NO_DRIVER_CARD_IW_DATA",
|
||||
"Vehicle-unit XML did not contain driver-card insertion/withdrawal records to build driver sessions.",
|
||||
"/VehicleUnit/Activities"
|
||||
));
|
||||
}
|
||||
|
||||
List<ExtractedCardActivityInterval> vuActivityIntervals = extractActivityIntervals(document, vehicleContext, sessionWarnings);
|
||||
assignActivityCoverage(vuActivityIntervals, vuCardIwIntervals, vehicleContext, driversByKey, sessionWarnings);
|
||||
extractSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, sessionWarnings);
|
||||
|
||||
List<ExtractionWarning> allWarnings = new ArrayList<>(sessionWarnings);
|
||||
Map<String, DriverExtractionSession> driverSessions = new LinkedHashMap<>();
|
||||
int activityCount = 0;
|
||||
int vehicleUsageCount = 0;
|
||||
for (DriverExtractionBuilder builder : driversByKey.values()) {
|
||||
builder.vehicleUsageIntervals.sort(Comparator.comparing(ExtractedCardVehicleUsageInterval::from));
|
||||
allWarnings.addAll(builder.warnings);
|
||||
activityCount += builder.cardActivityIntervals.size();
|
||||
vehicleUsageCount += builder.vehicleUsageIntervals.size();
|
||||
driverSessions.put(builder.driverKey, builder.build());
|
||||
}
|
||||
|
||||
int registrationCount = vehicleContext.registration() == null ? 0 : 1;
|
||||
int vehicleCount = vehicleContext.vehicle() == null ? 0 : 1;
|
||||
ExtractionStats stats = new ExtractionStats(
|
||||
driverSessions.size(),
|
||||
activityCount,
|
||||
vehicleUsageCount,
|
||||
registrationCount,
|
||||
vehicleCount,
|
||||
allWarnings.size()
|
||||
);
|
||||
|
||||
return new TachographFileSession(
|
||||
UUID.randomUUID(),
|
||||
metadata,
|
||||
Map.copyOf(driverSessions),
|
||||
stats,
|
||||
List.copyOf(allWarnings),
|
||||
createdAt,
|
||||
expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
private VehicleContext extractVehicleContext(Document document, List<ExtractionWarning> warnings) {
|
||||
Element overview = firstElement(document, "/VehicleUnit/Overview[1]");
|
||||
if (overview == null) {
|
||||
warnings.add(new ExtractionWarning(
|
||||
"MISSING_VU_OVERVIEW",
|
||||
"Vehicle-unit XML does not contain an Overview block.",
|
||||
"/VehicleUnit"
|
||||
));
|
||||
return new VehicleContext(null, null, null, null);
|
||||
}
|
||||
|
||||
String registrationNation = text(overview, "vehicleRegistrationIdentification/vehicleRegistrationNation");
|
||||
String registrationNumber = text(overview, "vehicleRegistrationIdentification/vehicleRegistrationNumber/vehicleRegNumber");
|
||||
ExtractedVehicleRegistration registration = null;
|
||||
String registrationKey = registrationNation == null && registrationNumber == null
|
||||
? null
|
||||
: vehicleKeyFactory.createRegistrationKey(registrationNation, registrationNumber);
|
||||
if (registrationKey != null) {
|
||||
registration = new ExtractedVehicleRegistration(
|
||||
registrationKey,
|
||||
vehicleKeyFactory.createSourceVehicleRegistrationId(registrationKey),
|
||||
registrationNation,
|
||||
registrationNumber
|
||||
);
|
||||
}
|
||||
|
||||
String vin = text(overview, "vehicleIdentificationNumber");
|
||||
ExtractedVehicle vehicle = null;
|
||||
String vehicleKey = vehicleKeyFactory.createVehicleKey(vin);
|
||||
if (vehicleKey != null) {
|
||||
vehicle = new ExtractedVehicle(
|
||||
vehicleKey,
|
||||
vehicleKeyFactory.createSourceVehicleId(vehicleKey),
|
||||
vin
|
||||
);
|
||||
}
|
||||
|
||||
OffsetDateTime defaultEnd = offsetDateTime(text(overview, "vuDownloadablePeriod/maxDownloadableTime"));
|
||||
OffsetDateTime defaultStart = offsetDateTime(text(overview, "vuDownloadablePeriod/minDownloadableTime"));
|
||||
return new VehicleContext(registration, vehicle, defaultStart, defaultEnd);
|
||||
}
|
||||
|
||||
private List<ExtractedCardActivityInterval> extractActivityIntervals(
|
||||
Document document,
|
||||
VehicleContext vehicleContext,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
NodeList dayRecords = nodes(document, "/VehicleUnit/Activities/vuActivityDailyData");
|
||||
if (dayRecords.getLength() == 0) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
LocalDate startDate = vehicleContext.defaultActivityStartDate();
|
||||
if (startDate == null) {
|
||||
warnings.add(new ExtractionWarning(
|
||||
"VU_ACTIVITY_DATE_INFERENCE_FAILED",
|
||||
"Vehicle-unit activity daily data is present but no base date could be inferred from the VU downloadable period.",
|
||||
"/VehicleUnit/Activities/vuActivityDailyData"
|
||||
));
|
||||
return List.of();
|
||||
}
|
||||
|
||||
LocalDate maxDate = vehicleContext.defaultActivityEndDate();
|
||||
if (maxDate != null) {
|
||||
LocalDate inferredEndDate = startDate.plusDays(dayRecords.getLength() - 1L);
|
||||
if (inferredEndDate.isAfter(maxDate)) {
|
||||
warnings.add(new ExtractionWarning(
|
||||
"VU_ACTIVITY_DATE_INFERENCE_RANGE",
|
||||
"Vehicle-unit activity daily records exceed the VU downloadable-period range when mapped by sequence order.",
|
||||
"/VehicleUnit/Activities/vuActivityDailyData"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
List<ExtractedCardActivityInterval> intervals = new ArrayList<>();
|
||||
int intervalNo = 0;
|
||||
for (int dayIndex = 0; dayIndex < dayRecords.getLength(); dayIndex++) {
|
||||
Element dayRecord = (Element) dayRecords.item(dayIndex);
|
||||
LocalDate date = startDate.plusDays(dayIndex);
|
||||
String dayPath = "/VehicleUnit/Activities[" + (dayIndex + 1) + "]/vuActivityDailyData";
|
||||
NodeList changes = nodes(dayRecord, "activityChangeInfos");
|
||||
List<ActivityChange> parsedChanges = new ArrayList<>();
|
||||
for (int changeIndex = 0; changeIndex < changes.getLength(); changeIndex++) {
|
||||
Element change = (Element) changes.item(changeIndex);
|
||||
OffsetDateTime from = combine(date, text(change, "timeOfChange"));
|
||||
if (from == null) {
|
||||
warnings.add(new ExtractionWarning(
|
||||
"INVALID_VU_ACTIVITY_CHANGE_TIME",
|
||||
"Vehicle-unit activity change has invalid timeOfChange.",
|
||||
dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
parsedChanges.add(new ActivityChange(
|
||||
from,
|
||||
normalizeActivity(text(change, "activity")),
|
||||
normalizeToken(text(change, "slot")),
|
||||
normalizeToken(text(change, "cardStatus")),
|
||||
normalizeToken(text(change, "drivingStatus")),
|
||||
dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
|
||||
));
|
||||
}
|
||||
parsedChanges.sort(Comparator.comparing(ActivityChange::from));
|
||||
for (int i = 0; i < parsedChanges.size(); i++) {
|
||||
ActivityChange current = parsedChanges.get(i);
|
||||
OffsetDateTime to = i + 1 < parsedChanges.size()
|
||||
? parsedChanges.get(i + 1).from()
|
||||
: date.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC);
|
||||
if (!current.from().isBefore(to)) {
|
||||
continue;
|
||||
}
|
||||
intervalNo++;
|
||||
intervals.add(new ExtractedCardActivityInterval(
|
||||
"VUACT-" + intervalNo,
|
||||
current.from(),
|
||||
to,
|
||||
current.activityType(),
|
||||
current.slot(),
|
||||
current.cardStatus(),
|
||||
current.drivingStatus(),
|
||||
null,
|
||||
null,
|
||||
current.rawRecordPath()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
intervals.sort(Comparator.comparing(ExtractedCardActivityInterval::from));
|
||||
return intervals;
|
||||
}
|
||||
|
||||
private void assignActivityCoverage(
|
||||
List<ExtractedCardActivityInterval> vuActivityIntervals,
|
||||
List<VuCardIwInterval> vuCardIwIntervals,
|
||||
VehicleContext vehicleContext,
|
||||
Map<String, DriverExtractionBuilder> driversByKey,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
for (ExtractedCardActivityInterval interval : vuActivityIntervals) {
|
||||
List<ActivitySegment> segments = splitByDriverCoverage(interval, vuCardIwIntervals);
|
||||
if (segments.isEmpty()) {
|
||||
warnings.add(new ExtractionWarning(
|
||||
"VU_ACTIVITY_UNASSIGNED",
|
||||
"Vehicle-unit activity interval could not be assigned to a driver-card insertion/withdrawal interval.",
|
||||
interval.rawRecordPath()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if (isPartiallyCovered(interval, segments)) {
|
||||
warnings.add(new ExtractionWarning(
|
||||
"VU_ACTIVITY_UNASSIGNED",
|
||||
"Vehicle-unit activity interval could only be partially assigned to driver-card insertion/withdrawal intervals.",
|
||||
interval.rawRecordPath()
|
||||
));
|
||||
}
|
||||
|
||||
for (int i = 0; i < segments.size(); i++) {
|
||||
ActivitySegment segment = segments.get(i);
|
||||
DriverExtractionBuilder builder = driversByKey.get(segment.driverKey());
|
||||
if (builder == null) {
|
||||
warnings.add(new ExtractionWarning(
|
||||
"VU_ACTIVITY_DRIVER_MISSING",
|
||||
"Vehicle-unit activity interval matched a driver key without an initialized driver session.",
|
||||
interval.rawRecordPath()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
String intervalId = segments.size() == 1 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1);
|
||||
builder.cardActivityIntervals.add(new ExtractedCardActivityInterval(
|
||||
intervalId,
|
||||
segment.from(),
|
||||
segment.to(),
|
||||
interval.activityType(),
|
||||
interval.slot(),
|
||||
interval.cardStatus(),
|
||||
interval.drivingStatus(),
|
||||
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
|
||||
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
|
||||
interval.rawRecordPath()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void extractSupportEvents(
|
||||
Document document,
|
||||
VehicleContext vehicleContext,
|
||||
List<VuCardIwInterval> vuCardIwIntervals,
|
||||
Map<String, DriverExtractionBuilder> driversByKey,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
extractVuPlaceSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
|
||||
extractVuGnssSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
|
||||
extractVuSpecificConditionSupportEvents(document, vehicleContext, vuCardIwIntervals, driversByKey, warnings);
|
||||
}
|
||||
|
||||
private void extractVuPlaceSupportEvents(
|
||||
Document document,
|
||||
VehicleContext vehicleContext,
|
||||
List<VuCardIwInterval> vuCardIwIntervals,
|
||||
Map<String, DriverExtractionBuilder> driversByKey,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
NodeList records = nodes(document, "/VehicleUnit/Activities/vuPlaceDailyWorkPeriodData/vuPlaceDailyWorkPeriodRecords");
|
||||
for (int i = 0; i < records.getLength(); i++) {
|
||||
Element record = (Element) records.item(i);
|
||||
String path = "/VehicleUnit/Activities/vuPlaceDailyWorkPeriodData/vuPlaceDailyWorkPeriodRecords[" + (i + 1) + "]";
|
||||
OffsetDateTime occurredAt = offsetDateTime(text(record, "placeRecord/entryTime"));
|
||||
if (occurredAt == null) {
|
||||
warnings.add(new ExtractionWarning("VU_PLACE_MISSING_TIME", "Vehicle-unit place record is missing entryTime.", path));
|
||||
continue;
|
||||
}
|
||||
|
||||
String entryType = text(record, "placeRecord/entryTypeDailyWorkPeriod");
|
||||
List<DriverAssignment> assignments = resolveDriverAssignments(
|
||||
occurredAt,
|
||||
driverKeyFromCardNode(record, "fullCardNumber"),
|
||||
null,
|
||||
vuCardIwIntervals
|
||||
);
|
||||
if (assignments.isEmpty()) {
|
||||
warnings.add(new ExtractionWarning("VU_PLACE_UNASSIGNED", "Vehicle-unit place record could not be assigned to a driver session.", path));
|
||||
continue;
|
||||
}
|
||||
if (assignments.size() > 1) {
|
||||
warnings.add(new ExtractionWarning("VU_PLACE_AMBIGUOUS", "Vehicle-unit place record matched multiple active driver sessions.", path));
|
||||
}
|
||||
|
||||
BigDecimal latitude = geoCoordinate(record, "placeRecord/entryGnssPlaceRecord/geoCoordinates/latitude", true);
|
||||
BigDecimal longitude = geoCoordinate(record, "placeRecord/entryGnssPlaceRecord/geoCoordinates/longitude", false);
|
||||
String authenticationStatus = normalizeToken(text(record, "placeRecord/entryGnssPlaceRecord/authenticationStatus"));
|
||||
for (DriverAssignment assignment : assignments) {
|
||||
addSupportEvent(
|
||||
driversByKey,
|
||||
assignment,
|
||||
new ExtractedSupportEvent(
|
||||
"VUPLACE-" + (i + 1) + "-" + assignment.driverKey(),
|
||||
occurredAt,
|
||||
"PLACE",
|
||||
mapPlaceEntryType(entryType),
|
||||
assignment.slot(),
|
||||
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
|
||||
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
|
||||
text(record, "placeRecord/dailyWorkPeriodCountry"),
|
||||
text(record, "placeRecord/dailyWorkPeriodRegion"),
|
||||
latitude,
|
||||
longitude,
|
||||
authenticationStatus,
|
||||
longValue(text(record, "placeRecord/vehicleOdometerValue")),
|
||||
normalizeToken(entryType),
|
||||
path
|
||||
),
|
||||
warnings
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void extractVuGnssSupportEvents(
|
||||
Document document,
|
||||
VehicleContext vehicleContext,
|
||||
List<VuCardIwInterval> vuCardIwIntervals,
|
||||
Map<String, DriverExtractionBuilder> driversByKey,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
NodeList records = nodes(document, "/VehicleUnit/Activities/vuGnssADData/vuGnssADRecord");
|
||||
for (int i = 0; i < records.getLength(); i++) {
|
||||
Element record = (Element) records.item(i);
|
||||
String path = "/VehicleUnit/Activities/vuGnssADData/vuGnssADRecord[" + (i + 1) + "]";
|
||||
OffsetDateTime occurredAt = offsetDateTime(text(record, "timeStamp"));
|
||||
if (occurredAt == null) {
|
||||
warnings.add(new ExtractionWarning("VU_GNSS_MISSING_TIME", "Vehicle-unit GNSS record is missing timeStamp.", path));
|
||||
continue;
|
||||
}
|
||||
|
||||
List<DriverAssignment> assignments = new ArrayList<>();
|
||||
assignments.addAll(resolveDriverAssignments(
|
||||
occurredAt,
|
||||
driverKeyFromCardNode(record, "cardNumberDriverSlot"),
|
||||
"DRIVER",
|
||||
vuCardIwIntervals
|
||||
));
|
||||
assignments.addAll(resolveDriverAssignments(
|
||||
occurredAt,
|
||||
driverKeyFromCardNode(record, "cardNumberCodriverSlot"),
|
||||
"CO_DRIVER",
|
||||
vuCardIwIntervals
|
||||
));
|
||||
assignments = distinctAssignments(assignments);
|
||||
if (assignments.isEmpty()) {
|
||||
warnings.add(new ExtractionWarning("VU_GNSS_UNASSIGNED", "Vehicle-unit GNSS record could not be assigned to a driver session.", path));
|
||||
continue;
|
||||
}
|
||||
|
||||
BigDecimal latitude = geoCoordinate(record, "gnssPlaceRecord/geoCoordinates/latitude", true);
|
||||
BigDecimal longitude = geoCoordinate(record, "gnssPlaceRecord/geoCoordinates/longitude", false);
|
||||
String authenticationStatus = normalizeToken(text(record, "gnssPlaceRecord/authenticationStatus"));
|
||||
for (DriverAssignment assignment : assignments) {
|
||||
addSupportEvent(
|
||||
driversByKey,
|
||||
assignment,
|
||||
new ExtractedSupportEvent(
|
||||
"VUGNSS-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
|
||||
occurredAt,
|
||||
"POSITION",
|
||||
"GNSS_ACCUMULATED_DRIVING",
|
||||
assignment.slot(),
|
||||
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
|
||||
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
|
||||
null,
|
||||
null,
|
||||
latitude,
|
||||
longitude,
|
||||
authenticationStatus,
|
||||
longValue(text(record, "vehicleOdometerValue")),
|
||||
null,
|
||||
path
|
||||
),
|
||||
warnings
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void extractVuSpecificConditionSupportEvents(
|
||||
Document document,
|
||||
VehicleContext vehicleContext,
|
||||
List<VuCardIwInterval> vuCardIwIntervals,
|
||||
Map<String, DriverExtractionBuilder> driversByKey,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
NodeList records = nodes(document, "/VehicleUnit/Activities/vuSpecificConditionData/specificConditionRecords");
|
||||
for (int i = 0; i < records.getLength(); i++) {
|
||||
Element record = (Element) records.item(i);
|
||||
String path = "/VehicleUnit/Activities/vuSpecificConditionData/specificConditionRecords[" + (i + 1) + "]";
|
||||
OffsetDateTime occurredAt = offsetDateTime(text(record, "entryTime"));
|
||||
if (occurredAt == null) {
|
||||
warnings.add(new ExtractionWarning("VU_SPECIFIC_CONDITION_MISSING_TIME", "Vehicle-unit specific-condition record is missing entryTime.", path));
|
||||
continue;
|
||||
}
|
||||
|
||||
List<DriverAssignment> assignments = distinctAssignments(resolveDriverAssignments(
|
||||
occurredAt,
|
||||
null,
|
||||
null,
|
||||
vuCardIwIntervals
|
||||
));
|
||||
if (assignments.isEmpty()) {
|
||||
warnings.add(new ExtractionWarning("VU_SPECIFIC_CONDITION_UNASSIGNED", "Vehicle-unit specific-condition record could not be assigned to an active driver session.", path));
|
||||
continue;
|
||||
}
|
||||
if (assignments.size() > 1) {
|
||||
warnings.add(new ExtractionWarning("VU_SPECIFIC_CONDITION_AMBIGUOUS", "Vehicle-unit specific-condition record matched multiple active driver sessions.", path));
|
||||
}
|
||||
|
||||
String conditionCode = normalizeToken(text(record, "specificConditionType"));
|
||||
for (DriverAssignment assignment : assignments) {
|
||||
addSupportEvent(
|
||||
driversByKey,
|
||||
assignment,
|
||||
new ExtractedSupportEvent(
|
||||
"VUSC-" + (i + 1) + "-" + assignment.driverKey() + "-" + assignment.slot(),
|
||||
occurredAt,
|
||||
"SPECIFIC_CONDITION",
|
||||
"SPECIFIC_CONDITION",
|
||||
assignment.slot(),
|
||||
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
|
||||
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
conditionCode,
|
||||
path
|
||||
),
|
||||
warnings
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPartiallyCovered(ExtractedCardActivityInterval interval, List<ActivitySegment> segments) {
|
||||
if (segments.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (!segments.get(0).from().equals(interval.from())) {
|
||||
return true;
|
||||
}
|
||||
if (!segments.get(segments.size() - 1).to().equals(interval.to())) {
|
||||
return true;
|
||||
}
|
||||
for (int i = 1; i < segments.size(); i++) {
|
||||
if (!segments.get(i - 1).to().equals(segments.get(i).from())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<ActivitySegment> splitByDriverCoverage(
|
||||
ExtractedCardActivityInterval interval,
|
||||
List<VuCardIwInterval> vuCardIwIntervals
|
||||
) {
|
||||
TreeSet<OffsetDateTime> cutPoints = new TreeSet<>();
|
||||
cutPoints.add(interval.from());
|
||||
cutPoints.add(interval.to());
|
||||
|
||||
List<VuCardIwInterval> matchingIntervals = vuCardIwIntervals.stream()
|
||||
.filter(iw -> iw.slot() == null || interval.slot() == null || iw.slot().equals(interval.slot()))
|
||||
.filter(iw -> iw.overlaps(interval.from(), interval.to()))
|
||||
.toList();
|
||||
|
||||
for (VuCardIwInterval iw : matchingIntervals) {
|
||||
if (iw.from().isAfter(interval.from()) && iw.from().isBefore(interval.to())) {
|
||||
cutPoints.add(iw.from());
|
||||
}
|
||||
OffsetDateTime iwEndExclusive = iw.endExclusive();
|
||||
if (iwEndExclusive.isAfter(interval.from()) && iwEndExclusive.isBefore(interval.to())) {
|
||||
cutPoints.add(iwEndExclusive);
|
||||
}
|
||||
}
|
||||
|
||||
List<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints);
|
||||
List<ActivitySegment> segments = new ArrayList<>();
|
||||
for (int i = 0; i < orderedCutPoints.size() - 1; i++) {
|
||||
OffsetDateTime segmentFrom = orderedCutPoints.get(i);
|
||||
OffsetDateTime segmentTo = orderedCutPoints.get(i + 1);
|
||||
if (!segmentFrom.isBefore(segmentTo)) {
|
||||
continue;
|
||||
}
|
||||
VuCardIwInterval covering = matchingIntervals.stream()
|
||||
.filter(iw -> iw.covers(segmentFrom))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (covering != null) {
|
||||
segments.add(new ActivitySegment(covering.driverKey(), segmentFrom, segmentTo));
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
private void addSupportEvent(
|
||||
Map<String, DriverExtractionBuilder> driversByKey,
|
||||
DriverAssignment assignment,
|
||||
ExtractedSupportEvent supportEvent,
|
||||
List<ExtractionWarning> warnings
|
||||
) {
|
||||
DriverExtractionBuilder builder = driversByKey.get(assignment.driverKey());
|
||||
if (builder == null) {
|
||||
warnings.add(new ExtractionWarning(
|
||||
"VU_SUPPORT_EVENT_DRIVER_MISSING",
|
||||
"Support event matched a driver key without an initialized driver session.",
|
||||
supportEvent.rawRecordPath()
|
||||
));
|
||||
return;
|
||||
}
|
||||
builder.supportEvents.add(supportEvent);
|
||||
}
|
||||
|
||||
private List<DriverAssignment> resolveDriverAssignments(
|
||||
OffsetDateTime occurredAt,
|
||||
String explicitDriverKey,
|
||||
String explicitSlot,
|
||||
List<VuCardIwInterval> vuCardIwIntervals
|
||||
) {
|
||||
if (explicitDriverKey != null) {
|
||||
return List.of(new DriverAssignment(explicitDriverKey, explicitSlot));
|
||||
}
|
||||
return vuCardIwIntervals.stream()
|
||||
.filter(iw -> explicitSlot == null || explicitSlot.equals(iw.slot()))
|
||||
.filter(iw -> iw.covers(occurredAt))
|
||||
.map(iw -> new DriverAssignment(iw.driverKey(), iw.slot()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<DriverAssignment> distinctAssignments(List<DriverAssignment> assignments) {
|
||||
Map<String, DriverAssignment> unique = new LinkedHashMap<>();
|
||||
for (DriverAssignment assignment : assignments) {
|
||||
unique.putIfAbsent(assignment.driverKey() + "|" + assignment.slot(), assignment);
|
||||
}
|
||||
return List.copyOf(unique.values());
|
||||
}
|
||||
|
||||
private String driverKeyFromCardNode(Element node, String basePath) {
|
||||
String cardNation = text(node, basePath + "/cardIssuingMemberState");
|
||||
String cardNumber = joinCardNumber(node, basePath + "/cardNumber");
|
||||
return cardNumber == null ? null : driverKeyFactory.createDriverKey(cardNation, cardNumber);
|
||||
}
|
||||
|
||||
private Element firstElement(Object node, String expression) {
|
||||
NodeList nodes = nodes(node, expression);
|
||||
if (nodes.getLength() == 0) {
|
||||
return null;
|
||||
}
|
||||
return (Element) nodes.item(0);
|
||||
}
|
||||
|
||||
private NodeList nodes(Object node, String expression) {
|
||||
return xml.nodes(node, expression);
|
||||
}
|
||||
|
||||
private String text(Object node, String expression) {
|
||||
return xml.text(node, expression);
|
||||
}
|
||||
|
||||
private OffsetDateTime offsetDateTime(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return OffsetDateTime.parse(value.trim()).withOffsetSameInstant(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
private Long longValue(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Long.parseLong(value.trim());
|
||||
}
|
||||
|
||||
private BigDecimal geoCoordinate(Object node, String expression, boolean latitude) {
|
||||
String value = text(node, expression);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
BigDecimal raw = new BigDecimal(value);
|
||||
BigDecimal abs = raw.abs();
|
||||
BigDecimal degreeThreshold = BigDecimal.valueOf(latitude ? 90D : 180D);
|
||||
if (abs.compareTo(degreeThreshold) <= 0) {
|
||||
return raw;
|
||||
}
|
||||
BigDecimal sign = raw.signum() < 0 ? BigDecimal.valueOf(-1L) : BigDecimal.ONE;
|
||||
BigDecimal absolute = raw.abs();
|
||||
BigDecimal[] degreeAndRemainder = absolute.divideAndRemainder(BigDecimal.valueOf(1000L));
|
||||
BigDecimal degrees = degreeAndRemainder[0];
|
||||
BigDecimal minutes = degreeAndRemainder[1].divide(BigDecimal.TEN);
|
||||
BigDecimal decimalDegrees = degrees.add(minutes.divide(BigDecimal.valueOf(60L), 8, java.math.RoundingMode.HALF_UP));
|
||||
return decimalDegrees.multiply(sign);
|
||||
}
|
||||
|
||||
private OffsetDateTime combine(LocalDate date, String timeText) {
|
||||
return TachographTimeParser.combineUtc(date, timeText);
|
||||
}
|
||||
|
||||
private String normalizeActivity(String value) {
|
||||
String normalized = normalizeToken(value);
|
||||
if (normalized == null) {
|
||||
return "UNKNOWN_ACTIVITY";
|
||||
}
|
||||
return switch (normalized) {
|
||||
case "DRIVING", "DRIVE" -> "DRIVE";
|
||||
case "WORK" -> "WORK";
|
||||
case "AVAILABILITY", "AVAILABLE" -> "AVAILABILITY";
|
||||
case "BREAK_REST", "BREAK/REST", "REST" -> "BREAK_REST";
|
||||
default -> "UNKNOWN_ACTIVITY";
|
||||
};
|
||||
}
|
||||
|
||||
private String normalizeToken(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim().toUpperCase().replace('-', '_').replace(' ', '_');
|
||||
}
|
||||
|
||||
private String mapPlaceEntryType(String entryType) {
|
||||
String normalized = normalizeToken(entryType);
|
||||
if (normalized == null) {
|
||||
return "DAILY_WORK_PERIOD_PLACE";
|
||||
}
|
||||
return switch (normalized) {
|
||||
case "0" -> "BEGIN_DAILY_WORK_PERIOD";
|
||||
case "1" -> "END_DAILY_WORK_PERIOD";
|
||||
case "2" -> "BEGIN_MANUAL_DAILY_WORK_PERIOD";
|
||||
case "3" -> "END_MANUAL_DAILY_WORK_PERIOD";
|
||||
case "4" -> "BEGIN_ASSUMED_DAILY_WORK_PERIOD";
|
||||
case "5" -> "END_ASSUMED_DAILY_WORK_PERIOD";
|
||||
case "6" -> "BEGIN_GNSS_DAILY_WORK_PERIOD";
|
||||
case "7" -> "END_GNSS_DAILY_WORK_PERIOD";
|
||||
default -> "DAILY_WORK_PERIOD_PLACE";
|
||||
};
|
||||
}
|
||||
|
||||
private String joinCardNumber(Element node, String basePath) {
|
||||
String driverIdentification = text(node, basePath + "/driverIdentification");
|
||||
if (driverIdentification == null) {
|
||||
return null;
|
||||
}
|
||||
String replacement = text(node, basePath + "/cardReplacementIndex");
|
||||
String renewal = text(node, basePath + "/cardRenewalIndex");
|
||||
StringBuilder builder = new StringBuilder(driverIdentification);
|
||||
if (replacement != null) {
|
||||
builder.append(replacement);
|
||||
}
|
||||
if (renewal != null) {
|
||||
builder.append(renewal);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static final class DriverExtractionBuilder {
|
||||
private final String driverKey;
|
||||
private final String sourceDriverId;
|
||||
private ExtractedDriver driver;
|
||||
private ExtractedDriverCard driverCard;
|
||||
private final Map<String, ExtractedVehicleRegistration> registrationsByKey = new LinkedHashMap<>();
|
||||
private final Map<String, ExtractedVehicle> vehiclesByKey = new LinkedHashMap<>();
|
||||
private final List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals = new ArrayList<>();
|
||||
private final List<ExtractedCardActivityInterval> cardActivityIntervals = new ArrayList<>();
|
||||
private final List<ExtractedSupportEvent> supportEvents = new ArrayList<>();
|
||||
private final List<ExtractionWarning> warnings = new ArrayList<>();
|
||||
|
||||
private DriverExtractionBuilder(String driverKey, String sourceDriverId) {
|
||||
this.driverKey = driverKey;
|
||||
this.sourceDriverId = sourceDriverId;
|
||||
}
|
||||
|
||||
private void mergeDriver(String surname, String firstNames) {
|
||||
if (driver == null) {
|
||||
driver = new ExtractedDriver(
|
||||
driverKey,
|
||||
sourceDriverId,
|
||||
surname,
|
||||
firstNames,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void mergeDriverCard(
|
||||
String sourceDriverCardId,
|
||||
String cardNation,
|
||||
String cardNumber,
|
||||
String issuingAuthorityName,
|
||||
OffsetDateTime issueDate,
|
||||
OffsetDateTime validityBegin,
|
||||
OffsetDateTime expiryDate
|
||||
) {
|
||||
if (driverCard == null) {
|
||||
driverCard = new ExtractedDriverCard(
|
||||
sourceDriverCardId,
|
||||
cardNation,
|
||||
cardNumber,
|
||||
issuingAuthorityName,
|
||||
issueDate,
|
||||
validityBegin,
|
||||
expiryDate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void addVehicleContext(ExtractedVehicleRegistration registration, ExtractedVehicle vehicle) {
|
||||
if (registration != null) {
|
||||
registrationsByKey.putIfAbsent(registration.registrationKey(), registration);
|
||||
}
|
||||
if (vehicle != null) {
|
||||
vehiclesByKey.putIfAbsent(vehicle.vehicleKey(), vehicle);
|
||||
}
|
||||
}
|
||||
|
||||
private DriverExtractionSession build() {
|
||||
return new DriverExtractionSession(
|
||||
driverKey,
|
||||
driver,
|
||||
driverCard,
|
||||
List.copyOf(registrationsByKey.values()),
|
||||
List.copyOf(vehiclesByKey.values()),
|
||||
List.copyOf(vehicleUsageIntervals),
|
||||
List.copyOf(cardActivityIntervals),
|
||||
List.copyOf(supportEvents),
|
||||
List.copyOf(warnings)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private record VehicleContext(
|
||||
ExtractedVehicleRegistration registration,
|
||||
ExtractedVehicle vehicle,
|
||||
OffsetDateTime defaultActivityStart,
|
||||
OffsetDateTime defaultOpenIntervalEnd
|
||||
) {
|
||||
private LocalDate defaultActivityStartDate() {
|
||||
return defaultActivityStart == null ? null : defaultActivityStart.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate();
|
||||
}
|
||||
|
||||
private LocalDate defaultActivityEndDate() {
|
||||
return defaultOpenIntervalEnd == null ? null : defaultOpenIntervalEnd.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate();
|
||||
}
|
||||
}
|
||||
|
||||
private record ActivityChange(
|
||||
OffsetDateTime from,
|
||||
String activityType,
|
||||
String slot,
|
||||
String cardStatus,
|
||||
String drivingStatus,
|
||||
String rawRecordPath
|
||||
) {
|
||||
}
|
||||
|
||||
private record VuCardIwInterval(
|
||||
String driverKey,
|
||||
String slot,
|
||||
OffsetDateTime from,
|
||||
OffsetDateTime to,
|
||||
String rawRecordPath
|
||||
) {
|
||||
private OffsetDateTime endExclusive() {
|
||||
return to.plusSeconds(1);
|
||||
}
|
||||
|
||||
private boolean covers(OffsetDateTime timestamp) {
|
||||
return !from.isAfter(timestamp) && timestamp.isBefore(endExclusive());
|
||||
}
|
||||
|
||||
private boolean overlaps(OffsetDateTime rangeStart, OffsetDateTime rangeEnd) {
|
||||
return endExclusive().isAfter(rangeStart) && from.isBefore(rangeEnd);
|
||||
}
|
||||
}
|
||||
|
||||
private record ActivitySegment(
|
||||
String driverKey,
|
||||
OffsetDateTime from,
|
||||
OffsetDateTime to
|
||||
) {
|
||||
}
|
||||
|
||||
private record DriverAssignment(
|
||||
String driverKey,
|
||||
String slot
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -121,6 +121,23 @@ eventhub:
|
|||
initial-occurred-to: "2026-04-10T00:00:00+01:00"
|
||||
run-initial-on-startup: true
|
||||
|
||||
tachograph-file-session:
|
||||
ttl: 4h
|
||||
max-sessions: 100
|
||||
max-file-size-bytes: 20971520
|
||||
processing:
|
||||
operating-split-idle-hours: 7
|
||||
significant-driving-minutes: 3
|
||||
merge-gap-seconds: 0
|
||||
gap-detection-tolerance-seconds: 0
|
||||
legal-requirements:
|
||||
base-url: ${LEGAL_REQUIREMENTS_BASE_URL:https://legalrequirements.services.bytebar.eu/ODataV4/LR}
|
||||
username: ${LEGAL_REQUIREMENTS_USERNAME:}
|
||||
password: ${LEGAL_REQUIREMENTS_PASSWORD:}
|
||||
connect-timeout: 20s
|
||||
read-timeout: 2m
|
||||
reset-session-after-use: false
|
||||
|
||||
esper-poc:
|
||||
activity-merge-mode: JAVA
|
||||
shift-resolution-mode: JAVA
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,34 @@
|
|||
package at.procon.eventhub.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EventHubPropertiesTest {
|
||||
|
||||
@Test
|
||||
void legalRequirementsBaseUrlIsNormalizedWhenPrefixedWithColonAndWhitespace() {
|
||||
EventHubProperties.LegalRequirements legalRequirements = new EventHubProperties.LegalRequirements();
|
||||
|
||||
legalRequirements.setBaseUrl(": https://legalrequirements.services.bytebar.eu/ODataV4/LR/");
|
||||
|
||||
assertEquals("https://legalrequirements.services.bytebar.eu/ODataV4/LR", legalRequirements.getBaseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void legalRequirementsBaseUrlFallsBackToDefaultWhenBlank() {
|
||||
EventHubProperties.LegalRequirements legalRequirements = new EventHubProperties.LegalRequirements();
|
||||
|
||||
legalRequirements.setBaseUrl(" ");
|
||||
|
||||
assertEquals("https://legalrequirements.services.bytebar.eu/ODataV4/LR", legalRequirements.getBaseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void legalRequirementsResetSessionIsDisabledByDefault() {
|
||||
EventHubProperties.LegalRequirements legalRequirements = new EventHubProperties.LegalRequirements();
|
||||
|
||||
assertFalse(legalRequirements.isResetSessionAfterUse());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package at.procon.eventhub.tachographfilesession.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
|
||||
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
|
||||
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService;
|
||||
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
class TachographFileSessionControllerTest {
|
||||
|
||||
@Test
|
||||
void uploadsSessionListsDriversAndDeletes() throws Exception {
|
||||
TachographFileSessionService service = org.mockito.Mockito.mock(TachographFileSessionService.class);
|
||||
TachographFileSessionProcessingService processingService = org.mockito.Mockito.mock(TachographFileSessionProcessingService.class);
|
||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TachographFileSessionController(service, processingService))
|
||||
.setControllerAdvice(new TachographFileSessionExceptionHandler())
|
||||
.build();
|
||||
UUID sessionId = UUID.randomUUID();
|
||||
TachographFileDriverSummaryDto driver = new TachographFileDriverSummaryDto("12:123", "Muster", "Max", "12", "123", 3, 2);
|
||||
TachographFileSessionSummaryDto summary = new TachographFileSessionSummaryDto(
|
||||
sessionId,
|
||||
"default",
|
||||
"legalrequirements-drivercard",
|
||||
"sample",
|
||||
"sample.ddd",
|
||||
true,
|
||||
"42",
|
||||
new ExtractionStats(1, 3, 2, 2, 2, 0),
|
||||
List.of(driver),
|
||||
List.of(),
|
||||
Instant.parse("2026-05-12T10:00:00Z"),
|
||||
Instant.parse("2026-05-12T14:00:00Z")
|
||||
);
|
||||
when(service.createSession(org.mockito.ArgumentMatchers.any(), eq("default"), eq("src"), eq("sample")))
|
||||
.thenReturn(new CreateTachographFileSessionResponse(summary));
|
||||
when(service.getSession(sessionId)).thenReturn(summary);
|
||||
when(service.listDrivers(sessionId)).thenReturn(new TachographFileSessionListDriversResponse(sessionId, List.of(driver)));
|
||||
when(service.getDriver(sessionId, "12:123")).thenReturn(new TachographFileDriverDetailDto(sessionId, "12:123", null, null, List.of(), List.of(), List.of(), List.of(), List.of(), List.of()));
|
||||
when(processingService.evaluateOperatingPeriods(eq(sessionId), eq("12:123"), org.mockito.ArgumentMatchers.any(TachographOperatingPeriodsProcessingRequest.class)))
|
||||
.thenReturn(new TachographOperatingPeriodsProcessingResultDto(
|
||||
sessionId,
|
||||
"12:123",
|
||||
Instant.parse("2026-05-12T08:00:00Z").atOffset(java.time.ZoneOffset.UTC),
|
||||
Instant.parse("2026-05-12T12:00:00Z").atOffset(java.time.ZoneOffset.UTC),
|
||||
Instant.parse("2026-05-12T08:00:00Z").atOffset(java.time.ZoneOffset.UTC),
|
||||
Instant.parse("2026-05-12T12:00:00Z").atOffset(java.time.ZoneOffset.UTC),
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of()
|
||||
));
|
||||
when(service.deleteSession(sessionId)).thenReturn(new TachographFileSessionDeleteResponse(sessionId, true));
|
||||
|
||||
mockMvc.perform(multipart("/api/eventhub/tachograph-file-sessions")
|
||||
.file(new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes()))
|
||||
.param("tenantKey", "default")
|
||||
.param("sourceInstanceKey", "src")
|
||||
.param("sessionLabel", "sample"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.session.sessionId").value(sessionId.toString()));
|
||||
|
||||
mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.sessionId").value(sessionId.toString()));
|
||||
|
||||
mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers", sessionId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.drivers[0].driverKey").value("12:123"));
|
||||
|
||||
mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}", sessionId, "12:123"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.driverKey").value("12:123"));
|
||||
|
||||
mockMvc.perform(post("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}/processing/operating-periods", sessionId, "12:123")
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
{
|
||||
"significantDrivingMinutes": 5
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.driverKey").value("12:123"))
|
||||
.andExpect(jsonPath("$.operatingPeriodCount").value(1));
|
||||
|
||||
mockMvc.perform(delete("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(true));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DriverCardXmlExtractionServiceTest {
|
||||
|
||||
private final TachographXmlParser parser = new TachographXmlParser();
|
||||
private final DriverCardXmlExtractionService service = new DriverCardXmlExtractionService(
|
||||
new DriverKeyFactory(),
|
||||
new VehicleKeyFactory()
|
||||
);
|
||||
|
||||
@Test
|
||||
void extractsDriverCardVehiclesAndActivities() {
|
||||
TachographFileSession session = service.extract(
|
||||
parser.parse(DriverCardXmlSamples.validDriverCardXml()),
|
||||
new TachographFileSessionMetadata(
|
||||
"default",
|
||||
"legalrequirements-drivercard",
|
||||
"sample",
|
||||
"sample.ddd",
|
||||
"abc",
|
||||
10,
|
||||
"42",
|
||||
"def",
|
||||
true,
|
||||
null
|
||||
),
|
||||
Instant.now(),
|
||||
Instant.now().plus(4, ChronoUnit.HOURS)
|
||||
);
|
||||
|
||||
assertThat(session.driversByKey()).hasSize(1);
|
||||
DriverExtractionSession driver = session.driversByKey().values().iterator().next();
|
||||
assertThat(driver.driverKey()).isEqualTo("12:12345678901200");
|
||||
assertThat(driver.driver().surname()).isEqualTo("Muster");
|
||||
assertThat(driver.driverCard().sourceDriverCardId()).isEqualTo("CARD:12:12345678901200");
|
||||
assertThat(driver.vehicleRegistrations()).hasSize(2);
|
||||
assertThat(driver.vehicles()).hasSize(2);
|
||||
assertThat(driver.cardVehicleUsageIntervals()).hasSize(2);
|
||||
assertThat(driver.cardActivityIntervals()).hasSize(5);
|
||||
assertThat(driver.cardActivityIntervals().get(0).registrationKey()).isEqualTo("12:W-12345A");
|
||||
assertThat(driver.cardActivityIntervals().get(1).to()).isEqualTo(driver.cardVehicleUsageIntervals().get(0).to().plusSeconds(1));
|
||||
assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B");
|
||||
assertThat(driver.cardActivityIntervals().get(3).registrationKey()).isEqualTo("12:W-54321B");
|
||||
assertThat(driver.cardActivityIntervals().get(4).registrationKey()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsActivitiesWhenTimeOfChangeHasNoOffset() {
|
||||
TachographFileSession session = service.extract(
|
||||
parser.parse(DriverCardXmlSamples.validDriverCardXmlWithLocalActivityTimes()),
|
||||
new TachographFileSessionMetadata(
|
||||
"default",
|
||||
"legalrequirements-drivercard",
|
||||
"sample",
|
||||
"sample.ddd",
|
||||
"abc",
|
||||
10,
|
||||
"42",
|
||||
"def",
|
||||
true,
|
||||
null
|
||||
),
|
||||
Instant.now(),
|
||||
Instant.now().plus(4, ChronoUnit.HOURS)
|
||||
);
|
||||
|
||||
DriverExtractionSession driver = session.driversByKey().values().iterator().next();
|
||||
assertThat(driver.cardActivityIntervals()).hasSize(5);
|
||||
assertThat(driver.cardActivityIntervals())
|
||||
.extracting(interval -> interval.from().toString())
|
||||
.contains("2026-04-01T08:00Z", "2026-04-01T12:30Z");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
final class DriverCardXmlSamples {
|
||||
|
||||
private DriverCardXmlSamples() {
|
||||
}
|
||||
|
||||
static String validDriverCardXml() {
|
||||
return """
|
||||
<DriverCard>
|
||||
<Identification>
|
||||
<cardIdentification>
|
||||
<cardIssuingMemberState>12</cardIssuingMemberState>
|
||||
<cardNumber>
|
||||
<driverIdentification>123456789012</driverIdentification>
|
||||
<cardReplacementIndex>0</cardReplacementIndex>
|
||||
<cardRenewalIndex>0</cardRenewalIndex>
|
||||
</cardNumber>
|
||||
<cardIssuingAuthorityName>
|
||||
<name>Authority</name>
|
||||
</cardIssuingAuthorityName>
|
||||
<cardIssueDate>2026-04-01T00:00:00Z</cardIssueDate>
|
||||
<cardValidityBegin>2026-04-01T00:00:00Z</cardValidityBegin>
|
||||
<cardExpiryDate>2031-04-01T00:00:00Z</cardExpiryDate>
|
||||
</cardIdentification>
|
||||
<driverCardHolderIdentification>
|
||||
<cardHolderName>
|
||||
<holderSurname><name>Muster</name></holderSurname>
|
||||
<holderFirstNames><name>Max</name></holderFirstNames>
|
||||
</cardHolderName>
|
||||
<cardHolderBirthDate>
|
||||
<year>1985</year>
|
||||
<month>06</month>
|
||||
<day>15</day>
|
||||
</cardHolderBirthDate>
|
||||
<cardHolderPreferredLanguage>de</cardHolderPreferredLanguage>
|
||||
</driverCardHolderIdentification>
|
||||
<signature></signature>
|
||||
</Identification>
|
||||
<DrivingLicenseInfo>
|
||||
<cardDrivingLicenseInformation>
|
||||
<drivingLicenceIssuingAuthority><name>Vienna</name></drivingLicenceIssuingAuthority>
|
||||
<drivingLicenceIssuingNation>12</drivingLicenceIssuingNation>
|
||||
<drivingLicenceNumber>B1234567</drivingLicenceNumber>
|
||||
</cardDrivingLicenseInformation>
|
||||
<signature></signature>
|
||||
</DrivingLicenseInfo>
|
||||
<VehiclesUsed>
|
||||
<cardVehiclesUsed>
|
||||
<vehiclePointerNewestRecord>1</vehiclePointerNewestRecord>
|
||||
<cardVehicleRecords>
|
||||
<vehicleOdometerBegin>1000</vehicleOdometerBegin>
|
||||
<vehicleOdometerEnd>1100</vehicleOdometerEnd>
|
||||
<vehicleFirstUse>2026-04-01T08:00:00Z</vehicleFirstUse>
|
||||
<vehicleLastUse>2026-04-01T11:59:59Z</vehicleLastUse>
|
||||
<vehicleRegistration>
|
||||
<vehicleRegistrationNation>12</vehicleRegistrationNation>
|
||||
<vehicleRegistrationNumber><vehicleRegNumber>W-12345A</vehicleRegNumber></vehicleRegistrationNumber>
|
||||
</vehicleRegistration>
|
||||
<vuDataBlockCounter>0001</vuDataBlockCounter>
|
||||
<vehicleIdentificationNumber>VIN12345678901234</vehicleIdentificationNumber>
|
||||
</cardVehicleRecords>
|
||||
<cardVehicleRecords>
|
||||
<vehicleOdometerBegin>1101</vehicleOdometerBegin>
|
||||
<vehicleOdometerEnd>1200</vehicleOdometerEnd>
|
||||
<vehicleFirstUse>2026-04-01T12:00:00Z</vehicleFirstUse>
|
||||
<vehicleLastUse>2026-04-01T18:00:00Z</vehicleLastUse>
|
||||
<vehicleRegistration>
|
||||
<vehicleRegistrationNation>12</vehicleRegistrationNation>
|
||||
<vehicleRegistrationNumber><vehicleRegNumber>W-54321B</vehicleRegNumber></vehicleRegistrationNumber>
|
||||
</vehicleRegistration>
|
||||
<vuDataBlockCounter>0002</vuDataBlockCounter>
|
||||
<vehicleIdentificationNumber>VIN99999999999999</vehicleIdentificationNumber>
|
||||
</cardVehicleRecords>
|
||||
</cardVehiclesUsed>
|
||||
<signature></signature>
|
||||
</VehiclesUsed>
|
||||
<DriverActivityData>
|
||||
<cardDriverActivity>
|
||||
<activityPointerOldestDayRecord>0</activityPointerOldestDayRecord>
|
||||
<activityPointerNewestRecord>1</activityPointerNewestRecord>
|
||||
<cardActivityDailyRecord>
|
||||
<activityRecordDate>2026-04-01T00:00:00Z</activityRecordDate>
|
||||
<activityDailyPresenceCounter>0001</activityDailyPresenceCounter>
|
||||
<activityDayDistance>200</activityDayDistance>
|
||||
<noOfActivityChanges>3</noOfActivityChanges>
|
||||
<activityChangeInfos>
|
||||
<slot>DRIVER</slot>
|
||||
<drivingStatus>SINGLE</drivingStatus>
|
||||
<cardStatus>INSERTED</cardStatus>
|
||||
<activity>WORK</activity>
|
||||
<timeOfChange>08:00:00Z</timeOfChange>
|
||||
</activityChangeInfos>
|
||||
<activityChangeInfos>
|
||||
<slot>DRIVER</slot>
|
||||
<drivingStatus>SINGLE</drivingStatus>
|
||||
<cardStatus>INSERTED</cardStatus>
|
||||
<activity>DRIVING</activity>
|
||||
<timeOfChange>09:00:00Z</timeOfChange>
|
||||
</activityChangeInfos>
|
||||
<activityChangeInfos>
|
||||
<slot>DRIVER</slot>
|
||||
<drivingStatus>SINGLE</drivingStatus>
|
||||
<cardStatus>INSERTED</cardStatus>
|
||||
<activity>BREAK/REST</activity>
|
||||
<timeOfChange>12:30:00Z</timeOfChange>
|
||||
</activityChangeInfos>
|
||||
</cardActivityDailyRecord>
|
||||
</cardDriverActivity>
|
||||
<signature></signature>
|
||||
</DriverActivityData>
|
||||
</DriverCard>
|
||||
""";
|
||||
}
|
||||
|
||||
static String validDriverCardXmlWithLocalActivityTimes() {
|
||||
return validDriverCardXml()
|
||||
.replace("<timeOfChange>08:00:00Z</timeOfChange>", "<timeOfChange>08:00:00</timeOfChange>")
|
||||
.replace("<timeOfChange>09:00:00Z</timeOfChange>", "<timeOfChange>09:00:00</timeOfChange>")
|
||||
.replace("<timeOfChange>12:30:00Z</timeOfChange>", "<timeOfChange>12:30:00</timeOfChange>");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import at.procon.eventhub.config.EventHubProperties;
|
||||
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
|
||||
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
class TachographFileSessionServiceTest {
|
||||
|
||||
@Test
|
||||
void createsAndLoadsSession() {
|
||||
EventHubProperties properties = new EventHubProperties();
|
||||
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
|
||||
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
|
||||
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
|
||||
DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class);
|
||||
VehicleUnitXmlExtractionService vehicleUnitExtractor = Mockito.mock(VehicleUnitXmlExtractionService.class);
|
||||
TachographFileSessionService service = new TachographFileSessionService(
|
||||
properties,
|
||||
repository,
|
||||
client,
|
||||
parser,
|
||||
driverCardExtractor,
|
||||
vehicleUnitExtractor
|
||||
);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes(StandardCharsets.UTF_8));
|
||||
when(client.uploadTachographFile(any(), eq("sample.ddd"))).thenReturn(new LegalRequirementsUploadResult("42", "<DriverCard/>"));
|
||||
TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
|
||||
when(parsed.rootElementName()).thenReturn("DriverCard");
|
||||
when(parser.parse("<DriverCard/>")).thenReturn(parsed);
|
||||
TachographFileSession extracted = new TachographFileSession(
|
||||
UUID.randomUUID(),
|
||||
new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null),
|
||||
Map.of(),
|
||||
new ExtractionStats(0, 0, 0, 0, 0, 0),
|
||||
java.util.List.of(),
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(10)
|
||||
);
|
||||
when(driverCardExtractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted);
|
||||
|
||||
CreateTachographFileSessionResponse response = service.createSession(file, null, null, "sample");
|
||||
|
||||
assertThat(response.session().sessionId()).isEqualTo(extracted.sessionId());
|
||||
assertThat(service.getSession(extracted.sessionId()).sessionId()).isEqualTo(extracted.sessionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedFileBeforeUpload() {
|
||||
EventHubProperties properties = new EventHubProperties();
|
||||
properties.getTachographFileSession().setMaxFileSizeBytes(1024);
|
||||
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
|
||||
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
|
||||
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
|
||||
DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class);
|
||||
VehicleUnitXmlExtractionService vehicleUnitExtractor = Mockito.mock(VehicleUnitXmlExtractionService.class);
|
||||
TachographFileSessionService service = new TachographFileSessionService(
|
||||
properties,
|
||||
repository,
|
||||
client,
|
||||
parser,
|
||||
driverCardExtractor,
|
||||
vehicleUnitExtractor
|
||||
);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", new byte[1025]);
|
||||
|
||||
assertThatThrownBy(() -> service.createSession(file, null, null, "sample"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("size limit");
|
||||
|
||||
verifyNoInteractions(client, parser, driverCardExtractor, vehicleUnitExtractor);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesVehicleUnitDefaultsAndExtractorForVehicleUnitXml() {
|
||||
EventHubProperties properties = new EventHubProperties();
|
||||
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
|
||||
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
|
||||
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
|
||||
DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class);
|
||||
VehicleUnitXmlExtractionService vehicleUnitExtractor = Mockito.mock(VehicleUnitXmlExtractionService.class);
|
||||
TachographFileSessionService service = new TachographFileSessionService(
|
||||
properties,
|
||||
repository,
|
||||
client,
|
||||
parser,
|
||||
driverCardExtractor,
|
||||
vehicleUnitExtractor
|
||||
);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile("file", "vu.ddd", "application/octet-stream", "vu".getBytes(StandardCharsets.UTF_8));
|
||||
when(client.uploadTachographFile(any(), eq("vu.ddd"))).thenReturn(new LegalRequirementsUploadResult("77", "<VehicleUnit/>"));
|
||||
TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
|
||||
when(parsed.rootElementName()).thenReturn("VehicleUnit");
|
||||
when(parser.parse("<VehicleUnit/>")).thenReturn(parsed);
|
||||
TachographFileSession extracted = new TachographFileSession(
|
||||
UUID.randomUUID(),
|
||||
new TachographFileSessionMetadata("default", "legalrequirements-vehicleunit", "vu", "vu.ddd", "a", 2, "77", "b", false, null),
|
||||
Map.of(),
|
||||
new ExtractionStats(0, 0, 0, 0, 0, 0),
|
||||
java.util.List.of(),
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(10)
|
||||
);
|
||||
when(vehicleUnitExtractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted);
|
||||
|
||||
CreateTachographFileSessionResponse response = service.createSession(file, null, null, "vu");
|
||||
|
||||
assertThat(response.session().driverCardFile()).isFalse();
|
||||
assertThat(response.session().sourceInstanceKey()).isEqualTo("legalrequirements-vehicleunit");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TachographTimeParserTest {
|
||||
|
||||
@Test
|
||||
void combinesPlainLocalTimeAsUtc() {
|
||||
assertThat(TachographTimeParser.combineUtc(LocalDate.of(2026, 4, 1), "00:00:00"))
|
||||
.hasToString("2026-04-01T00:00Z");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesDateShiftWhenOffsetTimeCrossesUtcMidnight() {
|
||||
assertThat(TachographTimeParser.combineUtc(LocalDate.of(2026, 4, 1), "00:30:00+02:00"))
|
||||
.hasToString("2026-03-31T22:30Z");
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullForInvalidTimeText() {
|
||||
assertThat(TachographTimeParser.combineUtc(LocalDate.of(2026, 4, 1), "not-a-time")).isNull();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TachographXmlParserTest {
|
||||
|
||||
private final TachographXmlParser parser = new TachographXmlParser();
|
||||
|
||||
@Test
|
||||
void parsesValidDriverCardXml() {
|
||||
TachographXmlParser.ParsedTachographXml parsed = parser.parse(DriverCardXmlSamples.validDriverCardXml());
|
||||
|
||||
assertThat(parsed.rootElementName()).isEqualTo("DriverCard");
|
||||
assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("DriverCard");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesValidVehicleUnitXml() {
|
||||
TachographXmlParser.ParsedTachographXml parsed = parser.parse("<VehicleUnit/>");
|
||||
|
||||
assertThat(parsed.rootElementName()).isEqualTo("VehicleUnit");
|
||||
assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("VehicleUnit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesXmlWithLeadingUtf8BomCharacter() {
|
||||
String xmlWithBom = "\uFEFF" + DriverCardXmlSamples.validDriverCardXml();
|
||||
|
||||
TachographXmlParser.ParsedTachographXml parsed = parser.parse(xmlWithBom);
|
||||
|
||||
assertThat(parsed.rootElementName()).isEqualTo("DriverCard");
|
||||
assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("DriverCard");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidXmlAgainstSchema() {
|
||||
String invalid = "<DriverCard><Identification></DriverCard>";
|
||||
|
||||
assertThatThrownBy(() -> parser.parse(invalid))
|
||||
.isInstanceOf(TachographXmlValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsXmlWithDoctype() {
|
||||
String xmlWithDoctype = """
|
||||
<!DOCTYPE DriverCard [
|
||||
<!ELEMENT DriverCard ANY>
|
||||
]>
|
||||
<DriverCard/>
|
||||
""";
|
||||
|
||||
assertThatThrownBy(() -> parser.parse(xmlWithDoctype))
|
||||
.isInstanceOf(TachographXmlValidationException.class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
|
||||
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
|
||||
import java.io.StringReader;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.w3c.dom.Document;
|
||||
import org.xml.sax.InputSource;
|
||||
|
||||
class VehicleUnitXmlExtractionServiceTest {
|
||||
|
||||
private final VehicleUnitXmlExtractionService service = new VehicleUnitXmlExtractionService(
|
||||
new DriverKeyFactory(),
|
||||
new VehicleKeyFactory()
|
||||
);
|
||||
|
||||
@Test
|
||||
void extractsVehicleUnitDriverSessionsFromCardInsertionWithdrawalData() throws Exception {
|
||||
TachographFileSession session = service.extract(
|
||||
new TachographXmlParser.ParsedTachographXml(document(VehicleUnitXmlSamples.vehicleUnitXml()), "VehicleUnit"),
|
||||
new TachographFileSessionMetadata(
|
||||
"default",
|
||||
"legalrequirements-vehicleunit",
|
||||
"sample-vu",
|
||||
"sample-vu.ddd",
|
||||
"abc",
|
||||
10,
|
||||
"42",
|
||||
"def",
|
||||
false,
|
||||
null
|
||||
),
|
||||
Instant.now(),
|
||||
Instant.now().plus(4, ChronoUnit.HOURS)
|
||||
);
|
||||
|
||||
assertThat(session.driversByKey()).hasSize(2);
|
||||
DriverExtractionSession firstDriver = session.driversByKey().get("12:12345678901200");
|
||||
DriverExtractionSession secondDriver = session.driversByKey().get("12:99999999999911");
|
||||
assertThat(firstDriver).isNotNull();
|
||||
assertThat(secondDriver).isNotNull();
|
||||
|
||||
assertThat(firstDriver.driver().surname()).isEqualTo("Muster");
|
||||
assertThat(firstDriver.vehicleRegistrations()).extracting("registrationKey").containsExactly("12:W-1000V");
|
||||
assertThat(firstDriver.vehicles()).extracting("vehicleKey").containsExactly("VINVU123456789012");
|
||||
assertThat(firstDriver.cardVehicleUsageIntervals()).hasSize(1);
|
||||
assertThat(firstDriver.cardVehicleUsageIntervals().get(0).from().toString()).isEqualTo("2026-04-01T08:00Z");
|
||||
assertThat(firstDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-01T11:00Z");
|
||||
assertThat(firstDriver.cardActivityIntervals()).hasSize(3);
|
||||
assertThat(firstDriver.cardActivityIntervals().get(0).activityType()).isEqualTo("WORK");
|
||||
assertThat(firstDriver.cardActivityIntervals().get(1).activityType()).isEqualTo("DRIVE");
|
||||
assertThat(firstDriver.cardActivityIntervals().get(2).to().toString()).isEqualTo("2026-04-01T11:00:01Z");
|
||||
assertThat(firstDriver.supportEvents()).hasSize(1);
|
||||
assertThat(firstDriver.supportEvents().get(0).eventDomain()).isEqualTo("PLACE");
|
||||
assertThat(firstDriver.supportEvents().get(0).eventType()).isEqualTo("BEGIN_DAILY_WORK_PERIOD");
|
||||
assertThat(firstDriver.supportEvents().get(0).country()).isEqualTo("12");
|
||||
assertThat(firstDriver.supportEvents().get(0).latitude()).isNotNull();
|
||||
|
||||
assertThat(secondDriver.cardVehicleUsageIntervals()).hasSize(1);
|
||||
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-02T10:00Z");
|
||||
assertThat(secondDriver.cardActivityIntervals()).hasSize(2);
|
||||
assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z");
|
||||
assertThat(secondDriver.cardActivityIntervals().get(1).to().toString()).isEqualTo("2026-04-02T10:00:01Z");
|
||||
assertThat(secondDriver.supportEvents()).hasSize(2);
|
||||
assertThat(secondDriver.supportEvents()).extracting("eventDomain").containsExactly("POSITION", "SPECIFIC_CONDITION");
|
||||
assertThat(secondDriver.supportEvents().get(0).latitude()).isNotNull();
|
||||
assertThat(secondDriver.supportEvents().get(1).code()).isEqualTo("1");
|
||||
|
||||
assertThat(session.warnings()).extracting("code")
|
||||
.contains("OPEN_VU_CARD_INTERVAL", "VU_ACTIVITY_UNASSIGNED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsActivitiesWhenVuTimeOfChangeHasNoOffset() throws Exception {
|
||||
TachographFileSession session = service.extract(
|
||||
new TachographXmlParser.ParsedTachographXml(document(VehicleUnitXmlSamples.vehicleUnitXmlWithLocalActivityTimes()), "VehicleUnit"),
|
||||
new TachographFileSessionMetadata(
|
||||
"default",
|
||||
"legalrequirements-vehicleunit",
|
||||
"sample-vu",
|
||||
"sample-vu.ddd",
|
||||
"abc",
|
||||
10,
|
||||
"42",
|
||||
"def",
|
||||
false,
|
||||
null
|
||||
),
|
||||
Instant.now(),
|
||||
Instant.now().plus(4, ChronoUnit.HOURS)
|
||||
);
|
||||
|
||||
DriverExtractionSession firstDriver = session.driversByKey().get("12:12345678901200");
|
||||
DriverExtractionSession secondDriver = session.driversByKey().get("12:99999999999911");
|
||||
assertThat(firstDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-01T08:00Z");
|
||||
assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z");
|
||||
}
|
||||
|
||||
private Document document(String xml) throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(false);
|
||||
return factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package at.procon.eventhub.tachographfilesession.service;
|
||||
|
||||
final class VehicleUnitXmlSamples {
|
||||
|
||||
private VehicleUnitXmlSamples() {
|
||||
}
|
||||
|
||||
static String vehicleUnitXml() {
|
||||
return """
|
||||
<VehicleUnit>
|
||||
<Overview>
|
||||
<vehicleIdentificationNumber>VINVU123456789012</vehicleIdentificationNumber>
|
||||
<vehicleRegistrationIdentification>
|
||||
<vehicleRegistrationNation>12</vehicleRegistrationNation>
|
||||
<vehicleRegistrationNumber><vehicleRegNumber>W-1000V</vehicleRegNumber></vehicleRegistrationNumber>
|
||||
</vehicleRegistrationIdentification>
|
||||
<vuDownloadablePeriod>
|
||||
<minDownloadableTime>2026-04-01T00:00:00Z</minDownloadableTime>
|
||||
<maxDownloadableTime>2026-04-02T10:00:00Z</maxDownloadableTime>
|
||||
</vuDownloadablePeriod>
|
||||
</Overview>
|
||||
<Activities>
|
||||
<vuCardIWData>
|
||||
<vuCardIWRecords>
|
||||
<cardHolderName>
|
||||
<holderSurname><name>Muster</name></holderSurname>
|
||||
<holderFirstNames><name>Max</name></holderFirstNames>
|
||||
</cardHolderName>
|
||||
<fullCardNumber>
|
||||
<cardType>DRIVER_CARD</cardType>
|
||||
<cardIssuingMemberState>12</cardIssuingMemberState>
|
||||
<cardNumber>
|
||||
<driverIdentification>123456789012</driverIdentification>
|
||||
<cardReplacementIndex>0</cardReplacementIndex>
|
||||
<cardRenewalIndex>0</cardRenewalIndex>
|
||||
</cardNumber>
|
||||
<generation>2</generation>
|
||||
</fullCardNumber>
|
||||
<cardExpiryDate>2031-04-01T00:00:00Z</cardExpiryDate>
|
||||
<cardInsertionTime>2026-04-01T08:00:00Z</cardInsertionTime>
|
||||
<vehicleOdometerValueAtInsertion>1000</vehicleOdometerValueAtInsertion>
|
||||
<cardSlotNumber>DRIVER</cardSlotNumber>
|
||||
<cardWithdrawalTime>2026-04-01T11:00:00Z</cardWithdrawalTime>
|
||||
<vehicleOdometerValueAtWithdrawal>1100</vehicleOdometerValueAtWithdrawal>
|
||||
<manualInputFlag>NO_ENTRY</manualInputFlag>
|
||||
</vuCardIWRecords>
|
||||
<vuCardIWRecords>
|
||||
<cardHolderName>
|
||||
<holderSurname><name>Test</name></holderSurname>
|
||||
<holderFirstNames><name>Tina</name></holderFirstNames>
|
||||
</cardHolderName>
|
||||
<fullCardNumber>
|
||||
<cardType>DRIVER_CARD</cardType>
|
||||
<cardIssuingMemberState>12</cardIssuingMemberState>
|
||||
<cardNumber>
|
||||
<driverIdentification>999999999999</driverIdentification>
|
||||
<cardReplacementIndex>1</cardReplacementIndex>
|
||||
<cardRenewalIndex>1</cardRenewalIndex>
|
||||
</cardNumber>
|
||||
<generation>2</generation>
|
||||
</fullCardNumber>
|
||||
<cardInsertionTime>2026-04-02T07:30:00Z</cardInsertionTime>
|
||||
<vehicleOdometerValueAtInsertion>1200</vehicleOdometerValueAtInsertion>
|
||||
<cardSlotNumber>DRIVER</cardSlotNumber>
|
||||
<manualInputFlag>MANUAL_ENTRIES</manualInputFlag>
|
||||
</vuCardIWRecords>
|
||||
</vuCardIWData>
|
||||
<vuActivityDailyData>
|
||||
<noOfActivityChanges>3</noOfActivityChanges>
|
||||
<activityChangeInfos>
|
||||
<slot>DRIVER</slot>
|
||||
<drivingStatus>SINGLE</drivingStatus>
|
||||
<cardStatus>INSERTED</cardStatus>
|
||||
<activity>WORK</activity>
|
||||
<timeOfChange>08:00:00Z</timeOfChange>
|
||||
</activityChangeInfos>
|
||||
<activityChangeInfos>
|
||||
<slot>DRIVER</slot>
|
||||
<drivingStatus>SINGLE</drivingStatus>
|
||||
<cardStatus>INSERTED</cardStatus>
|
||||
<activity>DRIVING</activity>
|
||||
<timeOfChange>09:00:00Z</timeOfChange>
|
||||
</activityChangeInfos>
|
||||
<activityChangeInfos>
|
||||
<slot>DRIVER</slot>
|
||||
<drivingStatus>SINGLE</drivingStatus>
|
||||
<cardStatus>INSERTED</cardStatus>
|
||||
<activity>BREAK/REST</activity>
|
||||
<timeOfChange>11:00:00Z</timeOfChange>
|
||||
</activityChangeInfos>
|
||||
</vuActivityDailyData>
|
||||
</Activities>
|
||||
<Activities>
|
||||
<vuActivityDailyData>
|
||||
<noOfActivityChanges>2</noOfActivityChanges>
|
||||
<activityChangeInfos>
|
||||
<slot>DRIVER</slot>
|
||||
<drivingStatus>SINGLE</drivingStatus>
|
||||
<cardStatus>INSERTED</cardStatus>
|
||||
<activity>WORK</activity>
|
||||
<timeOfChange>07:30:00Z</timeOfChange>
|
||||
</activityChangeInfos>
|
||||
<activityChangeInfos>
|
||||
<slot>DRIVER</slot>
|
||||
<drivingStatus>SINGLE</drivingStatus>
|
||||
<cardStatus>INSERTED</cardStatus>
|
||||
<activity>DRIVING</activity>
|
||||
<timeOfChange>08:00:00Z</timeOfChange>
|
||||
</activityChangeInfos>
|
||||
</vuActivityDailyData>
|
||||
<vuPlaceDailyWorkPeriodData>
|
||||
<vuPlaceDailyWorkPeriodRecords>
|
||||
<fullCardNumber>
|
||||
<cardType>DRIVER_CARD</cardType>
|
||||
<cardIssuingMemberState>12</cardIssuingMemberState>
|
||||
<cardNumber>
|
||||
<driverIdentification>123456789012</driverIdentification>
|
||||
<cardReplacementIndex>0</cardReplacementIndex>
|
||||
<cardRenewalIndex>0</cardRenewalIndex>
|
||||
</cardNumber>
|
||||
<generation>2</generation>
|
||||
</fullCardNumber>
|
||||
<placeRecord>
|
||||
<entryTime>2026-04-01T08:00:00Z</entryTime>
|
||||
<entryTypeDailyWorkPeriod>0</entryTypeDailyWorkPeriod>
|
||||
<dailyWorkPeriodCountry>12</dailyWorkPeriodCountry>
|
||||
<dailyWorkPeriodRegion>9</dailyWorkPeriodRegion>
|
||||
<vehicleOdometerValue>1000</vehicleOdometerValue>
|
||||
<entryGnssPlaceRecord>
|
||||
<timeStamp>2026-04-01T08:00:00Z</timeStamp>
|
||||
<gnssAccuracy>5</gnssAccuracy>
|
||||
<geoCoordinates>
|
||||
<latitude>48.2082</latitude>
|
||||
<longitude>16.3738</longitude>
|
||||
</geoCoordinates>
|
||||
<authenticationStatus>AUTHENTICATED</authenticationStatus>
|
||||
</entryGnssPlaceRecord>
|
||||
</placeRecord>
|
||||
</vuPlaceDailyWorkPeriodRecords>
|
||||
</vuPlaceDailyWorkPeriodData>
|
||||
<vuGnssADData>
|
||||
<vuGnssADRecord>
|
||||
<timeStamp>2026-04-02T08:00:00Z</timeStamp>
|
||||
<cardNumberDriverSlot>
|
||||
<cardType>DRIVER_CARD</cardType>
|
||||
<cardIssuingMemberState>12</cardIssuingMemberState>
|
||||
<cardNumber>
|
||||
<driverIdentification>999999999999</driverIdentification>
|
||||
<cardReplacementIndex>1</cardReplacementIndex>
|
||||
<cardRenewalIndex>1</cardRenewalIndex>
|
||||
</cardNumber>
|
||||
<generation>2</generation>
|
||||
</cardNumberDriverSlot>
|
||||
<gnssPlaceRecord>
|
||||
<timeStamp>2026-04-02T08:00:00Z</timeStamp>
|
||||
<gnssAccuracy>4</gnssAccuracy>
|
||||
<geoCoordinates>
|
||||
<latitude>48012</latitude>
|
||||
<longitude>16373</longitude>
|
||||
</geoCoordinates>
|
||||
<authenticationStatus>NOT_AUTHENTICATED</authenticationStatus>
|
||||
</gnssPlaceRecord>
|
||||
<vehicleOdometerValue>1210</vehicleOdometerValue>
|
||||
</vuGnssADRecord>
|
||||
</vuGnssADData>
|
||||
<vuSpecificConditionData>
|
||||
<specificConditionRecords>
|
||||
<entryTime>2026-04-02T09:00:00Z</entryTime>
|
||||
<specificConditionType>1</specificConditionType>
|
||||
</specificConditionRecords>
|
||||
</vuSpecificConditionData>
|
||||
</Activities>
|
||||
</VehicleUnit>
|
||||
""";
|
||||
}
|
||||
|
||||
static String vehicleUnitXmlWithLocalActivityTimes() {
|
||||
return vehicleUnitXml()
|
||||
.replace("<timeOfChange>08:00:00Z</timeOfChange>", "<timeOfChange>08:00:00</timeOfChange>")
|
||||
.replace("<timeOfChange>09:00:00Z</timeOfChange>", "<timeOfChange>09:00:00</timeOfChange>")
|
||||
.replace("<timeOfChange>11:00:00Z</timeOfChange>", "<timeOfChange>11:00:00</timeOfChange>")
|
||||
.replace("<timeOfChange>07:30:00Z</timeOfChange>", "<timeOfChange>07:30:00</timeOfChange>");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue