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": [
|
"variable": [
|
||||||
|
|
@ -227,6 +429,30 @@
|
||||||
{
|
{
|
||||||
"key": "occurredTo",
|
"key": "occurredTo",
|
||||||
"value": "2026-05-01T00:00:00Z"
|
"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 Batch batch = new Batch();
|
||||||
private final Tachograph tachograph = new Tachograph();
|
private final Tachograph tachograph = new Tachograph();
|
||||||
|
private final TachographFileSession tachographFileSession = new TachographFileSession();
|
||||||
private final EsperPoc esperPoc = new EsperPoc();
|
private final EsperPoc esperPoc = new EsperPoc();
|
||||||
private final YellowFox yellowFox = new YellowFox();
|
private final YellowFox yellowFox = new YellowFox();
|
||||||
|
|
||||||
|
|
@ -41,6 +42,10 @@ public class EventHubProperties {
|
||||||
return tachograph;
|
return tachograph;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public TachographFileSession getTachographFileSession() {
|
||||||
|
return tachographFileSession;
|
||||||
|
}
|
||||||
|
|
||||||
public EsperPoc getEsperPoc() {
|
public EsperPoc getEsperPoc() {
|
||||||
return esperPoc;
|
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 {
|
public static class TachographDataSource {
|
||||||
private String jdbcUrl;
|
private String jdbcUrl;
|
||||||
private String username;
|
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"
|
initial-occurred-to: "2026-04-10T00:00:00+01:00"
|
||||||
run-initial-on-startup: true
|
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:
|
esper-poc:
|
||||||
activity-merge-mode: JAVA
|
activity-merge-mode: JAVA
|
||||||
shift-resolution-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