Add in-memory tachograph file session flow

This commit is contained in:
trifonovt 2026-05-12 14:28:06 +02:00
parent d5a6254a33
commit 5a1061a314
42 changed files with 9069 additions and 0 deletions

View File

@ -30,6 +30,7 @@ public class EventHubProperties {
private final Batch batch = new Batch();
private final Tachograph tachograph = new Tachograph();
private final TachographFileSession tachographFileSession = new TachographFileSession();
private final EsperPoc esperPoc = new EsperPoc();
private final YellowFox yellowFox = new YellowFox();
@ -41,6 +42,10 @@ public class EventHubProperties {
return tachograph;
}
public TachographFileSession getTachographFileSession() {
return tachographFileSession;
}
public EsperPoc getEsperPoc() {
return esperPoc;
}
@ -308,6 +313,104 @@ 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();
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 static class LegalRequirements {
private String baseUrl = "https://legalrequirements.services.bytebar.eu/ODataV4/LR";
private String username;
private String password;
private Duration connectTimeout = Duration.ofSeconds(20);
private Duration readTimeout = Duration.ofMinutes(2);
private boolean resetSessionAfterUse = true;
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = 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;
}
}
public static class TachographDataSource {
private String jdbcUrl;
private String username;

View File

@ -0,0 +1,64 @@
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.TachographFileSessionDeleteResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
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.multipart.MultipartFile;
@RestController
@RequestMapping("/api/eventhub/tachograph-file-sessions")
public class TachographFileSessionController {
private final TachographFileSessionService service;
public TachographFileSessionController(TachographFileSessionService service) {
this.service = service;
}
@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));
}
@DeleteMapping("/{sessionId}")
public ResponseEntity<TachographFileSessionDeleteResponse> deleteSession(@PathVariable UUID sessionId) {
return ResponseEntity.ok(service.deleteSession(sessionId));
}
}

View File

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

View File

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

View File

@ -0,0 +1,24 @@
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.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<ExtractionWarning> warnings
) {
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
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<ExtractionWarning> warnings
) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,408 @@
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.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
@Component
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.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 = firstElement(document, "/DriverCard/Identification[1]");
if (identification == null) {
warnings.add(new ExtractionWarning("MISSING_IDENTIFICATION", "Driver card identification block is missing.", "/DriverCard"));
return null;
}
String cardNation = text(identification, "cardIdentification/cardIssuingMemberState");
String cardNumber = joinCardNumber(identification);
String authority = text(identification, "cardIdentification/cardIssuingAuthorityName/name");
return new ExtractedDriverCard(
null,
cardNation,
cardNumber,
authority,
offsetDateTime(text(identification, "cardIdentification/cardIssueDate")),
offsetDateTime(text(identification, "cardIdentification/cardValidityBegin")),
offsetDateTime(text(identification, "cardIdentification/cardExpiryDate"))
);
}
private ExtractedDriver extractDriver(Document document, String driverKey, List<ExtractionWarning> warnings) {
Element identification = firstElement(document, "/DriverCard/Identification[1]");
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 licenceInfo = firstElement(document, "/DriverCard/DrivingLicenseInfo[1]");
return new ExtractedDriver(
driverKey,
driverKeyFactory.createSourceDriverId(driverKey),
text(identification, "driverCardHolderIdentification/cardHolderName/holderSurname/name"),
text(identification, "driverCardHolderIdentification/cardHolderName/holderFirstNames/name"),
localDate(identification, "driverCardHolderIdentification/cardHolderBirthDate"),
text(identification, "driverCardHolderIdentification/cardHolderPreferredLanguage"),
text(licenceInfo, "cardDrivingLicenseInformation/drivingLicenceNumber"),
text(licenceInfo, "cardDrivingLicenseInformation/drivingLicenceIssuingNation"),
text(licenceInfo, "cardDrivingLicenseInformation/drivingLicenceIssuingAuthority/name")
);
}
private List<ExtractedCardVehicleUsageInterval> extractVehicleUsageIntervals(
Document document,
Map<String, ExtractedVehicleRegistration> registrationsByKey,
Map<String, ExtractedVehicle> vehiclesByKey,
List<ExtractionWarning> warnings
) {
NodeList records = nodes(document, "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords");
List<ExtractedCardVehicleUsageInterval> intervals = new ArrayList<>();
for (int i = 0; i < records.getLength(); i++) {
Element record = (Element) records.item(i);
String path = "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords[" + (i + 1) + "]";
OffsetDateTime from = offsetDateTime(text(record, "vehicleFirstUse"));
OffsetDateTime to = offsetDateTime(text(record, "vehicleLastUse"));
String registrationNation = text(record, "vehicleRegistration/vehicleRegistrationNation");
String registrationNumber = text(record, "vehicleRegistration/vehicleRegistrationNumber/vehicleRegNumber");
String registrationKey = vehicleKeyFactory.createRegistrationKey(registrationNation, registrationNumber);
registrationsByKey.putIfAbsent(
registrationKey,
new ExtractedVehicleRegistration(
registrationKey,
vehicleKeyFactory.createSourceVehicleRegistrationId(registrationKey),
registrationNation,
registrationNumber
)
);
String vin = text(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(text(record, "vehicleOdometerBegin")),
longValue(text(record, "vehicleOdometerEnd")),
registrationKey,
vehicleKey,
path
));
}
intervals.sort(Comparator.comparing(ExtractedCardVehicleUsageInterval::from));
return intervals;
}
private List<ExtractedCardActivityInterval> extractActivityIntervals(Document document, List<ExtractionWarning> warnings) {
NodeList dayRecords = nodes(document, "/DriverCard/DriverActivityData/cardDriverActivity/cardActivityDailyRecord");
List<ExtractedCardActivityInterval> intervals = new ArrayList<>();
int intervalNo = 0;
for (int dayIndex = 0; dayIndex < dayRecords.getLength(); dayIndex++) {
Element dayRecord = (Element) dayRecords.item(dayIndex);
String dayPath = "/DriverCard/DriverActivityData/cardDriverActivity/cardActivityDailyRecord[" + (dayIndex + 1) + "]";
OffsetDateTime recordDate = offsetDateTime(text(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();
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_ACTIVITY_CHANGE_TIME", "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(
"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());
for (ExtractedCardActivityInterval interval : activityIntervals) {
ExtractedCardVehicleUsageInterval covering = vehicleUsageIntervals.stream()
.filter(usage -> !usage.from().isAfter(interval.from()) && !usage.to().isBefore(interval.from()))
.findFirst()
.orElse(null);
result.add(new ExtractedCardActivityInterval(
interval.intervalId(),
interval.from(),
interval.to(),
interval.activityType(),
interval.slot(),
interval.cardStatus(),
interval.drivingStatus(),
covering == null ? null : covering.registrationKey(),
covering == null ? null : covering.vehicleKey(),
interval.rawRecordPath()
));
}
return result;
}
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) {
try {
XPath xpath = XPathFactory.newInstance().newXPath();
return (NodeList) xpath.evaluate(expression, node, XPathConstants.NODESET);
} catch (XPathExpressionException e) {
throw new IllegalStateException("Invalid XPath expression: " + expression, e);
}
}
private String text(Object node, String expression) {
try {
XPath xpath = XPathFactory.newInstance().newXPath();
String value = xpath.evaluate(expression, node);
return value == null || value.isBlank() ? null : value.trim();
} catch (XPathExpressionException e) {
throw new IllegalStateException("Invalid XPath expression: " + expression, e);
}
}
private OffsetDateTime offsetDateTime(String value) {
if (value == null || value.isBlank()) {
return null;
}
return OffsetDateTime.parse(value.trim()).withOffsetSameInstant(ZoneOffset.UTC);
}
private LocalDate localDate(Element element, String expression) {
Element dateElement = firstElement(element, expression);
if (dateElement == null) {
return null;
}
String year = text(dateElement, "year");
String month = text(dateElement, "month");
String day = text(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) {
if (date == null || timeText == null || timeText.isBlank()) {
return null;
}
LocalTime time = java.time.OffsetTime.parse(timeText.trim()).withOffsetSameInstant(ZoneOffset.UTC).toLocalTime();
return date.atTime(time).atOffset(ZoneOffset.UTC);
}
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) {
String driverIdentification = text(identification, "cardIdentification/cardNumber/driverIdentification");
if (driverIdentification == null) {
return null;
}
String replacement = text(identification, "cardIdentification/cardNumber/cardReplacementIndex");
String renewal = text(identification, "cardIdentification/cardNumber/cardRenewalIndex");
StringBuilder builder = new StringBuilder(driverIdentification);
if (replacement != null) {
builder.append(replacement);
}
if (renewal != null) {
builder.append(renewal);
}
return builder.toString();
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,126 @@
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 uploadDriverCard(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, "DataPackageID");
if (dataPackageId == null) {
dataPackageId = text(root, "DataPackageId");
}
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 (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
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 (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
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 (IOException | InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
private HttpRequest.Builder requestBuilder(String url, Duration timeout) {
return HttpRequest.newBuilder(URI.create(url)).timeout(timeout);
}
private String basicAuth(EventHubProperties.LegalRequirements config) {
String user = config.getUsername() == null ? "" : config.getUsername();
String password = config.getPassword() == null ? "" : config.getPassword();
String token = Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8));
return "Basic " + token;
}
private String text(JsonNode node, String field) {
if (node == null || !node.has(field) || node.get(field).isNull()) {
return null;
}
return node.get(field).asText();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,179 @@
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 extractionService;
public TachographFileSessionService(
EventHubProperties properties,
TachographFileSessionRepository repository,
LegalRequirementsClient legalRequirementsClient,
TachographXmlParser tachographXmlParser,
DriverCardXmlExtractionService extractionService
) {
this.properties = properties;
this.repository = repository;
this.legalRequirementsClient = legalRequirementsClient;
this.tachographXmlParser = tachographXmlParser;
this.extractionService = extractionService;
}
public CreateTachographFileSessionResponse createSession(
MultipartFile file,
String tenantKey,
String sourceInstanceKey,
String sessionLabel
) {
try {
byte[] fileBytes = file.getBytes();
validateFile(file, fileBytes);
LegalRequirementsUploadResult uploadResult = legalRequirementsClient.uploadDriverCard(fileBytes, file.getOriginalFilename());
TachographXmlParser.ParsedTachographXml parsedXml = tachographXmlParser.parseDriverCardXml(uploadResult.xmlContent());
Instant createdAt = Instant.now();
Instant expiresAt = createdAt.plus(properties.getTachographFileSession().getTtl());
TachographFileSessionMetadata metadata = new TachographFileSessionMetadata(
tenant(tenantKey),
sourceInstance(sourceInstanceKey),
blankToNull(sessionLabel),
file.getOriginalFilename(),
sha256(fileBytes),
fileBytes.length,
uploadResult.dataPackageId(),
sha256(uploadResult.xmlContent().getBytes(StandardCharsets.UTF_8)),
true,
null
);
TachographFileSession session = extractionService.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.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, byte[] fileBytes) {
if (file == null || file.isEmpty() || 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) {
return blankToNull(sourceInstanceKey) == null ? "legalrequirements-drivercard" : sourceInstanceKey.trim();
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String sha256(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 digest is not available.", e);
}
}
}

View File

@ -0,0 +1,74 @@
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 parseDriverCardXml(String xmlContent) {
try {
validate(xmlContent);
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new InputSource(new StringReader(xmlContent)));
String rootName = document.getDocumentElement() == null ? null : document.getDocumentElement().getNodeName();
if (!"DriverCard".equals(rootName)) {
throw new UnsupportedTachographFileTypeException("Only DriverCard XML documents are supported in phase 1.");
}
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 Schema loadSchema() {
try {
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
factory.setProperty(JAXP_MAX_OCCUR_LIMIT, 100_000);
byte[] content = new ClassPathResource("xsd/Tachograph.xsd").getContentAsByteArray();
return factory.newSchema(new StreamSource(new ByteArrayInputStream(content)));
} catch (IOException | SAXException e) {
throw new IllegalStateException("Failed to load Tachograph XSD schema.", e);
}
}
public record ParsedTachographXml(
Document document,
String rootElementName
) {
}
}

View File

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

View File

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

View File

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

View File

@ -121,6 +121,18 @@ eventhub:
initial-occurred-to: "2026-04-10T00:00:00+01:00"
run-initial-on-startup: true
tachograph-file-session:
ttl: 4h
max-sessions: 100
max-file-size-bytes: 20971520
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: true
esper-poc:
activity-merge-mode: JAVA
shift-resolution-mode: JAVA

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,82 @@
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.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.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;
class TachographFileSessionControllerTest {
@Test
void uploadsSessionListsDriversAndDeletes() throws Exception {
TachographFileSessionService service = org.mockito.Mockito.mock(TachographFileSessionService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TachographFileSessionController(service))
.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()));
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(delete("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.deleted").value(true));
}
}

View File

@ -0,0 +1,52 @@
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.parseDriverCardXml(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(3);
assertThat(driver.cardActivityIntervals().get(0).registrationKey()).isEqualTo("12:W-12345A");
assertThat(driver.cardActivityIntervals().get(2).registrationKey()).isEqualTo("12:W-54321B");
}
}

View File

@ -0,0 +1,115 @@
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>
""";
}
}

View File

@ -0,0 +1,52 @@
package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
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 extractor = Mockito.mock(DriverCardXmlExtractionService.class);
TachographFileSessionService service = new TachographFileSessionService(properties, repository, client, parser, extractor);
MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes(StandardCharsets.UTF_8));
when(client.uploadDriverCard(any(), eq("sample.ddd"))).thenReturn(new LegalRequirementsUploadResult("42", "<DriverCard/>"));
TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
when(parser.parseDriverCardXml("<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(extractor.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());
}
}

View File

@ -0,0 +1,27 @@
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.parseDriverCardXml(DriverCardXmlSamples.validDriverCardXml());
assertThat(parsed.rootElementName()).isEqualTo("DriverCard");
assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("DriverCard");
}
@Test
void rejectsInvalidXmlAgainstSchema() {
String invalid = "<DriverCard><Identification></DriverCard>";
assertThatThrownBy(() -> parser.parseDriverCardXml(invalid))
.isInstanceOf(TachographXmlValidationException.class);
}
}