Add vehicle unit tachograph file sessions

This commit is contained in:
trifonovt 2026-05-12 15:06:08 +02:00
parent 39714b90b3
commit 6b43a4b0e8
10 changed files with 650 additions and 26 deletions

View File

@ -248,6 +248,49 @@
}
}
},
{
"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": {
@ -374,6 +417,10 @@
{
"key": "tachographDddFile",
"value": "C:\\\\temp\\\\driver-card.ddd"
},
{
"key": "tachographVuDddFile",
"value": "C:\\\\temp\\\\vehicle-unit.ddd"
}
]
}

View File

@ -26,7 +26,7 @@ public class LegalRequirementsClient {
this.objectMapper = objectMapper;
}
public LegalRequirementsUploadResult uploadDriverCard(byte[] fileBytes, String fileName) {
public LegalRequirementsUploadResult uploadTachographFile(byte[] fileBytes, String fileName) {
EventHubProperties.LegalRequirements config = properties.getTachographFileSession().getLegalRequirements();
HttpClient client = HttpClient.newBuilder()
.connectTimeout(config.getConnectTimeout())

View File

@ -28,20 +28,23 @@ public class TachographFileSessionService {
private final TachographFileSessionRepository repository;
private final LegalRequirementsClient legalRequirementsClient;
private final TachographXmlParser tachographXmlParser;
private final DriverCardXmlExtractionService extractionService;
private final DriverCardXmlExtractionService driverCardExtractionService;
private final VehicleUnitXmlExtractionService vehicleUnitExtractionService;
public TachographFileSessionService(
EventHubProperties properties,
TachographFileSessionRepository repository,
LegalRequirementsClient legalRequirementsClient,
TachographXmlParser tachographXmlParser,
DriverCardXmlExtractionService extractionService
DriverCardXmlExtractionService driverCardExtractionService,
VehicleUnitXmlExtractionService vehicleUnitExtractionService
) {
this.properties = properties;
this.repository = repository;
this.legalRequirementsClient = legalRequirementsClient;
this.tachographXmlParser = tachographXmlParser;
this.extractionService = extractionService;
this.driverCardExtractionService = driverCardExtractionService;
this.vehicleUnitExtractionService = vehicleUnitExtractionService;
}
public CreateTachographFileSessionResponse createSession(
@ -54,23 +57,26 @@ public class TachographFileSessionService {
validateFile(file);
byte[] fileBytes = file.getBytes();
validateFileBytes(fileBytes);
LegalRequirementsUploadResult uploadResult = legalRequirementsClient.uploadDriverCard(fileBytes, file.getOriginalFilename());
TachographXmlParser.ParsedTachographXml parsedXml = tachographXmlParser.parseDriverCardXml(uploadResult.xmlContent());
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),
sourceInstance(sourceInstanceKey, driverCardFile),
blankToNull(sessionLabel),
file.getOriginalFilename(),
sha256(fileBytes),
fileBytes.length,
uploadResult.dataPackageId(),
sha256(uploadResult.xmlContent().getBytes(StandardCharsets.UTF_8)),
true,
driverCardFile,
null
);
TachographFileSession session = extractionService.extract(parsedXml, metadata, createdAt, expiresAt);
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) {
@ -171,8 +177,11 @@ public class TachographFileSessionService {
return blankToNull(tenantKey) == null ? "default" : tenantKey.trim();
}
private String sourceInstance(String sourceInstanceKey) {
return blankToNull(sourceInstanceKey) == null ? "legalrequirements-drivercard" : sourceInstanceKey.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) {

View File

@ -28,7 +28,7 @@ public class TachographXmlParser {
this.schema = loadSchema();
}
public ParsedTachographXml parseDriverCardXml(String xmlContent) {
public ParsedTachographXml parse(String xmlContent) {
try {
validate(xmlContent);
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
@ -44,8 +44,8 @@ public class TachographXmlParser {
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.");
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) {

View File

@ -0,0 +1,364 @@
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.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.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.NodeList;
@Component
public class VehicleUnitXmlExtractionService {
private final DriverKeyFactory driverKeyFactory;
private final VehicleKeyFactory vehicleKeyFactory;
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<>();
if (nodes(document, "/VehicleUnit/Activities/vuActivityDailyData").getLength() > 0) {
sessionWarnings.add(new ExtractionWarning(
"VU_ACTIVITY_NOT_EXTRACTED",
"Vehicle-unit daily activity data is present but is not yet mapped into card activity intervals in this phase.",
"/VehicleUnit/Activities/vuActivityDailyData"
));
}
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
));
}
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<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);
}
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"));
return new VehicleContext(registration, vehicle, defaultEnd);
}
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 Long longValue(String value) {
if (value == null || value.isBlank()) {
return null;
}
return Long.parseLong(value.trim());
}
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<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(warnings)
);
}
}
private record VehicleContext(
ExtractedVehicleRegistration registration,
ExtractedVehicle vehicle,
OffsetDateTime defaultOpenIntervalEnd
) {
}
}

View File

@ -20,7 +20,7 @@ class DriverCardXmlExtractionServiceTest {
@Test
void extractsDriverCardVehiclesAndActivities() {
TachographFileSession session = service.extract(
parser.parseDriverCardXml(DriverCardXmlSamples.validDriverCardXml()),
parser.parse(DriverCardXmlSamples.validDriverCardXml()),
new TachographFileSessionMetadata(
"default",
"legalrequirements-drivercard",

View File

@ -28,13 +28,22 @@ class TachographFileSessionServiceTest {
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);
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.uploadDriverCard(any(), eq("sample.ddd"))).thenReturn(new LegalRequirementsUploadResult("42", "<DriverCard/>"));
when(client.uploadTachographFile(any(), eq("sample.ddd"))).thenReturn(new LegalRequirementsUploadResult("42", "<DriverCard/>"));
TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
when(parser.parseDriverCardXml("<DriverCard/>")).thenReturn(parsed);
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),
@ -44,7 +53,7 @@ class TachographFileSessionServiceTest {
Instant.now(),
Instant.now().plusSeconds(10)
);
when(extractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted);
when(driverCardExtractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted);
CreateTachographFileSessionResponse response = service.createSession(file, null, null, "sample");
@ -59,8 +68,16 @@ class TachographFileSessionServiceTest {
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);
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]);
@ -68,6 +85,45 @@ class TachographFileSessionServiceTest {
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("size limit");
verifyNoInteractions(client, parser, extractor);
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");
}
}

View File

@ -11,17 +11,25 @@ class TachographXmlParserTest {
@Test
void parsesValidDriverCardXml() {
TachographXmlParser.ParsedTachographXml parsed = parser.parseDriverCardXml(DriverCardXmlSamples.validDriverCardXml());
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 rejectsInvalidXmlAgainstSchema() {
String invalid = "<DriverCard><Identification></DriverCard>";
assertThatThrownBy(() -> parser.parseDriverCardXml(invalid))
assertThatThrownBy(() -> parser.parse(invalid))
.isInstanceOf(TachographXmlValidationException.class);
}
@ -34,7 +42,7 @@ class TachographXmlParserTest {
<DriverCard/>
""";
assertThatThrownBy(() -> parser.parseDriverCardXml(xmlWithDoctype))
assertThatThrownBy(() -> parser.parse(xmlWithDoctype))
.isInstanceOf(TachographXmlValidationException.class);
}
}

View File

@ -0,0 +1,68 @@
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(secondDriver.cardVehicleUsageIntervals()).hasSize(1);
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-02T10:00Z");
assertThat(session.warnings()).extracting("code")
.contains("VU_ACTIVITY_NOT_EXTRACTED", "OPEN_VU_CARD_INTERVAL");
}
private Document document(String xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
return factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)));
}
}

View File

@ -0,0 +1,72 @@
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>
<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/>
</Activities>
</VehicleUnit>
""";
}
}