diff --git a/README.md b/README.md index 6895ed8..0a4b2a4 100644 --- a/README.md +++ b/README.md @@ -822,6 +822,8 @@ CARD_BORDER_CROSSING -> BORDER_CROSSING / DRIVER_CARD / CardBorderCrossing VU_BORDER_CROSSING -> BORDER_CROSSING / VEHICLE_UNIT / VUBorderCrossing CARD_LOAD_UNLOAD -> LOAD_UNLOAD / DRIVER_CARD / CardLoadUnload VU_LOAD_UNLOAD -> LOAD_UNLOAD / VEHICLE_UNIT / VULoadUnload +CARD_SPECIFIC_CONDITION -> SPECIFIC_CONDITION / DRIVER_CARD / CardSpecificCondition +VU_SPECIFIC_CONDITION -> SPECIFIC_CONDITION / VEHICLE_UNIT / VUSpecificCondition ``` SQL resources: @@ -829,11 +831,13 @@ SQL resources: ```text src/main/resources/sql/tachograph/card-border-crossing.sql src/main/resources/sql/tachograph/card-load-unload.sql +src/main/resources/sql/tachograph/card-specific-condition.sql src/main/resources/sql/tachograph/card-vehicles-used.sql src/main/resources/sql/tachograph/card-activity.sql src/main/resources/sql/tachograph/iw-cycle.sql src/main/resources/sql/tachograph/vu-border-crossing.sql src/main/resources/sql/tachograph/vu-load-unload.sql +src/main/resources/sql/tachograph/vu-specific-condition.sql src/main/resources/sql/tachograph/vu-activity.sql ``` diff --git a/src/main/java/at/procon/eventhub/service/EventDetailsFactory.java b/src/main/java/at/procon/eventhub/service/EventDetailsFactory.java index a67f03b..a6e2851 100644 --- a/src/main/java/at/procon/eventhub/service/EventDetailsFactory.java +++ b/src/main/java/at/procon/eventhub/service/EventDetailsFactory.java @@ -63,6 +63,10 @@ public class EventDetailsFactory { return new EventDetailsDto("LOAD_UNLOAD", objectMapper.valueToTree(attributes)); } + public EventDetailsDto specificCondition() { + return new EventDetailsDto("SPECIFIC_CONDITION", objectMapper.createObjectNode()); + } + public EventDetailsDto speeding(BigDecimal speedKmh, BigDecimal permittedSpeedKmh) { Map attributes = new LinkedHashMap<>(); put(attributes, "speedKmh", speedKmh); diff --git a/src/main/java/at/procon/eventhub/tachograph/service/AbstractTachographSpecificConditionRowMapper.java b/src/main/java/at/procon/eventhub/tachograph/service/AbstractTachographSpecificConditionRowMapper.java new file mode 100644 index 0000000..196f3d2 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachograph/service/AbstractTachographSpecificConditionRowMapper.java @@ -0,0 +1,199 @@ +package at.procon.eventhub.tachograph.service; + +import at.procon.eventhub.dto.DriverCardRefDto; +import at.procon.eventhub.dto.DriverRefDto; +import at.procon.eventhub.dto.EventDomain; +import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.dto.EventLifecycle; +import at.procon.eventhub.dto.EventType; +import at.procon.eventhub.dto.SourcePackageRefDto; +import at.procon.eventhub.dto.VehicleRefDto; +import at.procon.eventhub.dto.VehicleRegistrationRefDto; +import at.procon.eventhub.importing.extraction.ExtractionContext; +import at.procon.eventhub.importing.extraction.ExtractionRowMapper; +import at.procon.eventhub.service.EventDetailsFactory; +import at.procon.eventhub.tachograph.dto.TachographImportRequest; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +abstract class AbstractTachographSpecificConditionRowMapper implements ExtractionRowMapper { + + private final EventDetailsFactory detailsFactory; + + protected AbstractTachographSpecificConditionRowMapper(EventDetailsFactory detailsFactory) { + this.detailsFactory = detailsFactory; + } + + @Override + public EventHubEventDto map(ResultSet rs, int rowNum, ExtractionContext context) throws SQLException { + OffsetDateTime occurredAt = offsetDateTime(rs, "occurred_at"); + SourcePackageRefDto sourcePackageRef = sourcePackageRef(rs); + DriverRefDto driverRef = driverRef(rs); + VehicleRefDto vehicleRef = vehicleRef(rs); + EventDomain eventDomain = eventDomain(rs); + EventType eventType = eventType(rs); + EventLifecycle lifecycle = lifecycle(rs); + + String externalSourceEventId = string(rs, "external_source_event_id"); + if (externalSourceEventId == null) { + externalSourceEventId = defaultExternalSourceEventId(context, rowNum, occurredAt, sourcePackageRef, driverRef, vehicleRef, eventType, lifecycle); + } + + return new EventHubEventDto( + UUID.randomUUID(), + externalSourceEventId, + driverRef, + vehicleRef, + occurredAt, + offsetDateTime(rs, "received_partner_at"), + OffsetDateTime.now(), + eventDomain, + eventType, + lifecycle, + null, + null, + detailsFactory.specificCondition(), + sourcePackageRef != null && sourcePackageRef.hasAnyReference() ? sourcePackageRef : null, + detailsFactory.payloadFromMap(payload(rs)), + false, + context.packageInfo() + ); + } + + protected Map sourceSpecificPayload(ResultSet rs) throws SQLException { + return Map.of(); + } + + private DriverRefDto driverRef(ResultSet rs) throws SQLException { + DriverCardRefDto driverCard = null; + String cardNumber = string(rs, "driver_card_number"); + if (cardNumber != null) { + driverCard = new DriverCardRefDto(string(rs, "driver_card_nation"), cardNumber); + } + DriverRefDto driverRef = new DriverRefDto(string(rs, "driver_source_entity_id"), driverCard); + return driverRef.hasAnyReference() ? driverRef : null; + } + + private VehicleRefDto vehicleRef(ResultSet rs) throws SQLException { + VehicleRegistrationRefDto registration = null; + String registrationNumber = string(rs, "vehicle_registration_number"); + if (registrationNumber != null) { + registration = new VehicleRegistrationRefDto(string(rs, "vehicle_registration_nation"), registrationNumber); + } + VehicleRefDto vehicleRef = new VehicleRefDto( + string(rs, "vehicle_source_entity_id"), + string(rs, "vehicle_vin"), + string(rs, "vehicle_registration_source_entity_id"), + registration + ); + return vehicleRef.hasAnyReference() ? vehicleRef : null; + } + + private SourcePackageRefDto sourcePackageRef(ResultSet rs) throws SQLException { + return new SourcePackageRefDto( + string(rs, "source_package_kind"), + string(rs, "source_package_id"), + string(rs, "source_package_entity_id"), + offsetDateTime(rs, "source_package_period_from"), + offsetDateTime(rs, "source_package_period_to"), + offsetDateTime(rs, "source_package_imported_at") + ); + } + + private Map payload(ResultSet rs) throws SQLException { + Map raw = new LinkedHashMap<>(); + put(raw, "sourceRowId", string(rs, "source_row_id")); + raw.putAll(sourceSpecificPayload(rs)); + return Map.of("raw", raw); + } + + private String defaultExternalSourceEventId( + ExtractionContext context, + int rowNum, + OffsetDateTime occurredAt, + SourcePackageRefDto sourcePackageRef, + DriverRefDto driverRef, + VehicleRefDto vehicleRef, + EventType eventType, + EventLifecycle lifecycle + ) { + String sourcePackageId = sourcePackageRef == null || sourcePackageRef.sourcePackageId() == null + ? "NO_SOURCE_PACKAGE" + : sourcePackageRef.sourcePackageId(); + String subject = driverRef != null && driverRef.hasAnyReference() + ? driverRef.stableKey() + : vehicleRef == null ? "NO_SUBJECT" : vehicleRef.stableKey(); + return "TACHOGRAPH:" + context.planItem().extractionCode() + + ":" + sourcePackageId + + ":" + eventType + + ":" + lifecycle + + ":" + occurredAt + + ":" + subject + + ":ROW-" + rowNum; + } + + protected String string(ResultSet rs, String column) throws SQLException { + String value = rs.getString(column); + return value == null || value.isBlank() ? null : value.trim(); + } + + protected OffsetDateTime offsetDateTime(ResultSet rs, String column) throws SQLException { + Object value = rs.getObject(column); + if (value == null) { + return null; + } + if (value instanceof OffsetDateTime offsetDateTime) { + return offsetDateTime.withOffsetSameInstant(ZoneOffset.UTC); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().atOffset(ZoneOffset.UTC); + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime.atOffset(ZoneOffset.UTC); + } + String text = value.toString(); + try { + return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC); + } catch (RuntimeException ignored) { + return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC); + } + } + + private EventDomain eventDomain(ResultSet rs) throws SQLException { + return parseEnum(EventDomain.class, string(rs, "event_domain"), EventDomain.OUT_OF_SCOPE); + } + + private EventType eventType(ResultSet rs) throws SQLException { + return parseEnum(EventType.class, string(rs, "event_type"), EventType.UNKNOWN_EVENT); + } + + private EventLifecycle lifecycle(ResultSet rs) throws SQLException { + return parseEnum(EventLifecycle.class, string(rs, "lifecycle"), EventLifecycle.SNAPSHOT); + } + + private > T parseEnum(Class type, String value, T fallback) { + if (value == null) { + return fallback; + } + String normalized = value.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_'); + try { + return Enum.valueOf(type, normalized); + } catch (IllegalArgumentException ignored) { + return fallback; + } + } + + protected void put(Map target, String key, Object value) { + if (value != null) { + target.put(key, value); + } + } +} diff --git a/src/main/java/at/procon/eventhub/tachograph/service/CardSpecificConditionRowMapper.java b/src/main/java/at/procon/eventhub/tachograph/service/CardSpecificConditionRowMapper.java new file mode 100644 index 0000000..e9518c0 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachograph/service/CardSpecificConditionRowMapper.java @@ -0,0 +1,12 @@ +package at.procon.eventhub.tachograph.service; + +import at.procon.eventhub.service.EventDetailsFactory; +import org.springframework.stereotype.Component; + +@Component +public class CardSpecificConditionRowMapper extends AbstractTachographSpecificConditionRowMapper { + + public CardSpecificConditionRowMapper(EventDetailsFactory detailsFactory) { + super(detailsFactory); + } +} diff --git a/src/main/java/at/procon/eventhub/tachograph/service/TachographExtractionDefinitionRegistry.java b/src/main/java/at/procon/eventhub/tachograph/service/TachographExtractionDefinitionRegistry.java index 428c961..7ba0f2f 100644 --- a/src/main/java/at/procon/eventhub/tachograph/service/TachographExtractionDefinitionRegistry.java +++ b/src/main/java/at/procon/eventhub/tachograph/service/TachographExtractionDefinitionRegistry.java @@ -18,7 +18,9 @@ public class TachographExtractionDefinitionRegistry extends ExtractionDefinition VuBorderCrossingRowMapper vuBorderCrossingRowMapper, CardBorderCrossingRowMapper cardBorderCrossingRowMapper, VuLoadUnloadRowMapper vuLoadUnloadRowMapper, - CardLoadUnloadRowMapper cardLoadUnloadRowMapper + CardLoadUnloadRowMapper cardLoadUnloadRowMapper, + VuSpecificConditionRowMapper vuSpecificConditionRowMapper, + CardSpecificConditionRowMapper cardSpecificConditionRowMapper ) { super(List.of( new ExtractionDefinition<>( @@ -84,6 +86,22 @@ public class TachographExtractionDefinitionRegistry extends ExtractionDefinition "DRIVER", "classpath:sql/tachograph/card-load-unload.sql", cardLoadUnloadRowMapper + ), + new ExtractionDefinition<>( + "VU_SPECIFIC_CONDITION", + EventFamily.SPECIFIC_CONDITION, + "VEHICLE_UNIT", + "VEHICLE", + "classpath:sql/tachograph/vu-specific-condition.sql", + vuSpecificConditionRowMapper + ), + new ExtractionDefinition<>( + "CARD_SPECIFIC_CONDITION", + EventFamily.SPECIFIC_CONDITION, + "DRIVER_CARD", + "DRIVER", + "classpath:sql/tachograph/card-specific-condition.sql", + cardSpecificConditionRowMapper ) )); } diff --git a/src/main/java/at/procon/eventhub/tachograph/service/VuSpecificConditionRowMapper.java b/src/main/java/at/procon/eventhub/tachograph/service/VuSpecificConditionRowMapper.java new file mode 100644 index 0000000..109d9a4 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachograph/service/VuSpecificConditionRowMapper.java @@ -0,0 +1,12 @@ +package at.procon.eventhub.tachograph.service; + +import at.procon.eventhub.service.EventDetailsFactory; +import org.springframework.stereotype.Component; + +@Component +public class VuSpecificConditionRowMapper extends AbstractTachographSpecificConditionRowMapper { + + public VuSpecificConditionRowMapper(EventDetailsFactory detailsFactory) { + super(detailsFactory); + } +} diff --git a/src/main/resources/sql/tachograph/card-specific-condition.sql b/src/main/resources/sql/tachograph/card-specific-condition.sql new file mode 100644 index 0000000..958e7de --- /dev/null +++ b/src/main/resources/sql/tachograph/card-specific-condition.sql @@ -0,0 +1,88 @@ +/* + * CardSpecificCondition SPECIFIC_CONDITION extraction for the bytebar tachograph schema. + */ +with OrgTree as ( + select org.I_90021_OID + from dbo.GetOrganisationTree(null, :organisationId, 0, null) org + where :organisationId is not null +) +, +Base as ( + select + cond.ID, + cond.EntryTime as occurred_at, + sc.Condition as condition_code, + cond.ID_FileLog, + c.ID as card_id, + c.ID_Driver as driver_id, + cn.AlphaCode as driver_card_nation, + c.CardNumber as driver_card_number, + v.ID as vehicle_registration_id, + v.ID_VehicleIdentification as vehicle_identification_id, + vi.VIN as vehicle_vin, + vehicleNation.AlphaCode as vehicle_registration_nation, + v.VRN as vehicle_registration_number, + coalesce(fl.DownloadDate, fl.OriginalDownloadDate, fl.TStamp, fl.CreationDate) as received_partner_at, + coalesce(fl.ID, cond.ID_FileLog, cond.ID) as source_package_id_raw, + coalesce(fl.ID_Card, cond.ID_Card) as source_package_entity_id_raw, + coalesce(fl.DownloadFrom, cond.EntryTime) as source_package_period_from, + coalesce(fl.DownloadTo, cond.EntryTime) as source_package_period_to, + coalesce(fl.CreationDate, fl.TStamp) as source_package_imported_at + from dbo.CardSpecificConditions cond + join dbo.SpecificCondition sc on sc.ID = cond.ID_SpecificCondition + join dbo.Card c on c.ID = cond.ID_Card + left join dbo.Nation cn on cn.ID = c.ID_Nation + left join dbo.FileLog fl on fl.ID = cond.ID_FileLog + outer apply ( + select top 1 used.ID_Vehicle + from dbo.CardVehiclesUsed used + where used.ID_Card = cond.ID_Card + and (used.FirstUse is null or used.FirstUse <= cond.EntryTime) + and (used.LastUse is null or used.LastUse >= cond.EntryTime) + order by + used.FirstUse desc, + used.ID desc + ) cvu + left join dbo.Vehicle v on v.ID = cvu.ID_Vehicle + left join dbo.VehicleIdentification vi on vi.ID = v.ID_VehicleIdentification + left join dbo.Nation vehicleNation on vehicleNation.ID = v.ID_Nation + where (:occurredFrom is null or cond.EntryTime >= :occurredFrom) + and (:occurredTo is null or cond.EntryTime < :occurredTo) + and ( + :organisationId is null + or exists ( + select 1 + from dbo.Driver_I_90021 rel + join OrgTree on OrgTree.I_90021_OID = rel.ID_I_90021 + where rel.ID_Driver = c.ID_Driver + and rel.GILT_BIS is null + ) + ) +) +select + cast(base.ID as varchar(128)) as source_row_id, + concat('TACHOGRAPH:CARD_SPECIFIC_CONDITION:', base.ID) as external_source_event_id, + + base.occurred_at, + base.received_partner_at, + case when base.condition_code in (3, 4) then 'FERRY_TRAIN' else 'OUT_OF_SCOPE' end as event_domain, + case when base.condition_code in (3, 4) then 'FERRY_TRAIN' else 'OUT' end as event_type, + case when base.condition_code in (1, 3) then 'BEGIN' else 'END' end as lifecycle, + + cast(base.driver_id as varchar(128)) as driver_source_entity_id, + base.driver_card_nation, + base.driver_card_number, + + cast(base.vehicle_identification_id as varchar(128)) as vehicle_source_entity_id, + base.vehicle_vin, + cast(base.vehicle_registration_id as varchar(128)) as vehicle_registration_source_entity_id, + base.vehicle_registration_nation, + base.vehicle_registration_number, + + 'DRIVER_CARD' as source_package_kind, + cast(base.source_package_id_raw as varchar(128)) as source_package_id, + cast(base.source_package_entity_id_raw as varchar(128)) as source_package_entity_id, + base.source_package_period_from, + base.source_package_period_to, + base.source_package_imported_at +from Base base diff --git a/src/main/resources/sql/tachograph/vu-specific-condition.sql b/src/main/resources/sql/tachograph/vu-specific-condition.sql new file mode 100644 index 0000000..fe58061 --- /dev/null +++ b/src/main/resources/sql/tachograph/vu-specific-condition.sql @@ -0,0 +1,84 @@ +/* + * VUSpecificCondition SPECIFIC_CONDITION extraction for the bytebar tachograph schema. + */ +with OrgTree as ( + select org.I_90021_OID + from dbo.GetOrganisationTree(null, :organisationId, 0, null) org + where :organisationId is not null +) +, +Base as ( + select + cond.ID, + cond.EntryTime as occurred_at, + sc.Condition as condition_code, + cond.ID_FileLog, + cond.ID_VUInstallation, + vui.ID_FileLog as vui_filelog_id, + vui.ID_VehicleIdentification, + vi.ID as vehicle_identification_id, + vi.VIN as vehicle_vin, + coalesce(fl.DownloadDate, fl.OriginalDownloadDate, fl.TStamp, fl.CreationDate) as received_partner_at, + coalesce(fl.ID, cond.ID_FileLog, vui.ID_FileLog, cond.ID) as source_package_id_raw, + coalesce(fl.ID_VehicleIdentification, vui.ID_VehicleIdentification) as source_package_entity_id_raw, + coalesce(fl.DownloadFrom, cond.EntryTime) as source_package_period_from, + coalesce(fl.DownloadTo, cond.EntryTime) as source_package_period_to, + coalesce(fl.CreationDate, fl.TStamp) as source_package_imported_at + from dbo.VUSpecificConditions cond + join dbo.SpecificCondition sc on sc.ID = cond.ID_SpecificCondition + join dbo.VUInstallation vui on vui.ID = cond.ID_VUInstallation + join dbo.VehicleIdentification vi on vi.ID = vui.ID_VehicleIdentification + left join dbo.FileLog fl on fl.ID = coalesce(cond.ID_FileLog, vui.ID_FileLog) + where (:occurredFrom is null or cond.EntryTime >= :occurredFrom) + and (:occurredTo is null or cond.EntryTime < :occurredTo) +) +select + cast(base.ID as varchar(128)) as source_row_id, + concat('TACHOGRAPH:VU_SPECIFIC_CONDITION:', base.ID) as external_source_event_id, + + base.occurred_at, + base.received_partner_at, + case when base.condition_code in (3, 4) then 'FERRY_TRAIN' else 'OUT_OF_SCOPE' end as event_domain, + case when base.condition_code in (3, 4) then 'FERRY_TRAIN' else 'OUT' end as event_type, + case when base.condition_code in (1, 3) then 'BEGIN' else 'END' end as lifecycle, + + cast(null as varchar(128)) as driver_source_entity_id, + cast(null as varchar(16)) as driver_card_nation, + cast(null as varchar(64)) as driver_card_number, + + cast(base.vehicle_identification_id as varchar(128)) as vehicle_source_entity_id, + base.vehicle_vin, + cast(v.ID as varchar(128)) as vehicle_registration_source_entity_id, + vn.AlphaCode as vehicle_registration_nation, + v.VRN as vehicle_registration_number, + + 'VEHICLE_UNIT' as source_package_kind, + cast(base.source_package_id_raw as varchar(128)) as source_package_id, + cast(base.source_package_entity_id_raw as varchar(128)) as source_package_entity_id, + base.source_package_period_from, + base.source_package_period_to, + base.source_package_imported_at +from Base base +outer apply ( + select top 1 vehicle.ID, + vehicle.VRN, + vehicle.ID_Nation + from dbo.Vehicle vehicle + where vehicle.ID_VehicleIdentification = base.vehicle_identification_id + and (vehicle.ValidFrom is null or vehicle.ValidFrom <= base.occurred_at) + and (vehicle.ValidTo is null or vehicle.ValidTo > base.occurred_at) + order by + vehicle.ValidFrom desc, + vehicle.ID desc +) v +left join dbo.Nation vn on vn.ID = v.ID_Nation +where ( + :organisationId is null + or exists ( + select 1 + from dbo.Vehicle_I_90021 rel + join OrgTree on OrgTree.I_90021_OID = rel.ID_I_90021 + where rel.ID_Vehicle = v.ID + and rel.GILT_BIS is null + ) + ) diff --git a/src/test/java/at/procon/eventhub/tachograph/service/TachographImportPlanServiceTest.java b/src/test/java/at/procon/eventhub/tachograph/service/TachographImportPlanServiceTest.java index 70db0df..88de3ac 100644 --- a/src/test/java/at/procon/eventhub/tachograph/service/TachographImportPlanServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachograph/service/TachographImportPlanServiceTest.java @@ -26,7 +26,9 @@ class TachographImportPlanServiceTest { new VuBorderCrossingRowMapper(detailsFactory), new CardBorderCrossingRowMapper(detailsFactory), new VuLoadUnloadRowMapper(detailsFactory), - new CardLoadUnloadRowMapper(detailsFactory) + new CardLoadUnloadRowMapper(detailsFactory), + new VuSpecificConditionRowMapper(detailsFactory), + new CardSpecificConditionRowMapper(detailsFactory) ); @Test @@ -41,7 +43,8 @@ class TachographImportPlanServiceTest { .hasMessageContaining("DRIVER_ACTIVITY") .hasMessageContaining("DRIVER_CARD") .hasMessageContaining("BORDER_CROSSING") - .hasMessageContaining("LOAD_UNLOAD"); + .hasMessageContaining("LOAD_UNLOAD") + .hasMessageContaining("SPECIFIC_CONDITION"); } @Test @@ -92,6 +95,18 @@ class TachographImportPlanServiceTest { .containsExactlyInAnyOrder("VU_LOAD_UNLOAD", "CARD_LOAD_UNLOAD"); } + @Test + void allowsSupportedSpecificConditionFamilyWhenJdbcExtractionIsEnabled() { + TachographImportPlanService service = serviceWithJdbcExtractor(); + TachographImportRequest request = requestForFamilies(EventFamily.SPECIFIC_CONDITION); + + var plan = service.createPlan(request); + + assertThat(plan.items()) + .extracting(item -> item.extractionCode()) + .containsExactlyInAnyOrder("VU_SPECIFIC_CONDITION", "CARD_SPECIFIC_CONDITION"); + } + private TachographImportPlanService serviceWithJdbcExtractor() { EventHubProperties properties = new EventHubProperties(); properties.getTachograph().getDatasource().setJdbcUrl("jdbc:sqlserver://tachograph-db");