summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md16
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt32
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt361
3 files changed, 406 insertions, 3 deletions
diff --git a/README.md b/README.md
index 7eb4c93..58f158b 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,22 @@ Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in
![Modell DNPM-ETL-Strecke](docs/etl.png)
+### 🔥 Wichtige Änderungen in Version 0.16
+
+#### Erkennung von Follow-Ups
+
+Ab Version 0.16 werden Meldungen als Follow-Ups erkannt und entsprechend weiterleiten, wenn
+
+* die zu bearbeitende Meldung Follow-Ups enthält und das neueste Follow-Up nach der letzten Meldung für den Patienten datiert ist.
+* zuvor eine initiale Meldung bereits erfolgte, die akzeptiert wurde.
+
+Dies erfordert das Aktivieren der Funktion [Blockieren weiterer initialer Submissions](#blockieren-weiterer-initialer-submissions)
+
+#### Monitoring von ungültigen Anfragen
+
+Ungültige Anfragen werden nun protokolliert und können in der Übersicht eingesehen werden.
+Zuvor wurden diese ignoriert.
+
### 🔥 Wichtige Änderungen in Version 0.15
#### Konfiguration von Benutzern
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt
index b94fbbc..4b7afff 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt
@@ -139,11 +139,19 @@ class RequestProcessor(
hasSuccessfulInitialSubmission(request.patientPseudonym()) &&
!hasUnacceptedSuccessfulInitialSubmission(request.patientPseudonym())
) {
- // Use "addition" after "intial" with "Meldebestaetigung"
+ // Use "addition" or "followup" depending on existing follow-ups after "intial" with "Meldebestaetigung"
request.content.metadata?.let {
logger.warn("Override submission type using 'addition' after first initial submission!")
- it.type = MvhSubmissionType.ADDITION
- submissionType = SubmissionType.ADDITION
+ it.type = if (hasFollowUpAfterLastSuccessfulSubmission(request)) {
+ MvhSubmissionType.FOLLOWUP
+ } else {
+ MvhSubmissionType.ADDITION
+ }
+ submissionType = if (hasFollowUpAfterLastSuccessfulSubmission(request)) {
+ SubmissionType.FOLLOWUP
+ } else {
+ SubmissionType.ADDITION
+ }
}
}
@@ -184,6 +192,24 @@ class RequestProcessor(
)
}
+ private fun hasFollowUpAfterLastSuccessfulSubmission(request: DnpmV2MtbFileRequest): Boolean {
+ val lastSuccessfulSubmission = this.requestService.allRequestsByPatientPseudonym(request.patientPseudonym())
+ .sortedBy { it.processedAt }
+ .filterNot {
+ it.submissionType == SubmissionType.INITIAL &&
+ (it.status == RequestStatus.SUCCESS || it.status == RequestStatus.WARNING) &&
+ !(it.submissionAccepted || it.status == RequestStatus.BLOCKED_INITIAL)
+ }
+ .lastOrNull() ?: return false
+
+ val lastFollowUp = request.content.followUps
+ ?.filterNot { it.date == null }
+ ?.sortedBy { it.date }
+ ?.lastOrNull { it.date != null } ?: return false
+
+ return lastSuccessfulSubmission.processedAt.isBefore( lastFollowUp.date.toInstant())
+ }
+
private fun hasSuccessfulInitialSubmission(patientPseudonym: PatientPseudonym): Boolean {
return this.requestService.allRequestsByPatientPseudonym(patientPseudonym).any {
it.submissionType == SubmissionType.INITIAL &&
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt
index 5a89c03..6827a4f 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt
@@ -1136,6 +1136,367 @@ class RequestProcessorTest {
verify(sender, times(1)).send(sendRequestCaptor.capture())
assertThat(sendRequestCaptor.firstValue.content.metadata.type).isEqualTo(MvhSubmissionType.INITIAL)
}
+
+ @Test
+ fun testShouldSendFollowUpMtbFileIfUnacceptedErrorsAndAcceptedInitialFileWasSent() {
+
+ // One failed attempt and one successful but not accepted
+ val lastRequests =
+ listOf(
+ Request(
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("initial"),
+ RequestType.MTB_FILE,
+ SubmissionType.INITIAL,
+ RequestStatus.ERROR,
+ Tan.empty(),
+ Instant.parse("2026-02-10T09:00:00Z"),
+ submissionAccepted = false,
+ ),
+ Request(
+ 2L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("initial"),
+ RequestType.MTB_FILE,
+ SubmissionType.INITIAL,
+ RequestStatus.ERROR,
+ Tan.empty(),
+ Instant.parse("2026-02-11T09:00:00Z"),
+ submissionAccepted = false,
+ ),
+ Request(
+ 3L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("initial"),
+ RequestType.MTB_FILE,
+ SubmissionType.INITIAL,
+ RequestStatus.WARNING,
+ Tan.empty(),
+ Instant.parse("2026-02-12T09:00:00Z"),
+ submissionAccepted = true,
+ )
+ )
+
+ doAnswer { lastRequests }
+ .whenever(requestService)
+ .allRequestsByPatientPseudonym(anyValueClass())
+
+ doAnswer { it.arguments[0] as String }
+ .whenever(pseudonymizeService)
+ .patientPseudonym(anyValueClass())
+
+ doAnswer { it.arguments[0] }.whenever(transformationService).transform(any<Mtb>())
+
+ doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) }
+ .whenever(sender)
+ .send(any<DnpmV2MtbFileRequest>())
+
+ whenever(consentProcessor.consentGatedCheckAndTryEmbedding(any())).thenReturn(true)
+
+ requestProcessor =
+ RequestProcessor(
+ pseudonymizeService,
+ transformationService,
+ sender,
+ requestService,
+ jsonMapper,
+ applicationEventPublisher,
+ AppConfigProperties(postInitialSubmissionBlock = true),
+ consentProcessor,
+ )
+
+ val mtbFile =
+ Mtb.builder()
+ .patient(Patient.builder().id("123").build())
+ .metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
+ .episodesOfCare(
+ listOf(
+ MtbEpisodeOfCare.builder()
+ .id("1")
+ .patient(Reference.builder().id("123").build())
+ .period(
+ PeriodDate.builder()
+ .start(Date.from(Instant.parse("2026-01-20T00:00:00.00Z")))
+ .build()
+ )
+ .build()
+ )
+ )
+ .followUps(
+ listOf(
+ FollowUp.builder()
+ .patient(Reference.builder().id("123").build())
+ .date(Date.from(Instant.parse("2026-05-21T00:00:00.00Z")))
+ .lastContactDate(Date.from(Instant.parse("2026-05-21T00:00:00.00Z")))
+ .build()
+ )
+ )
+ .build()
+
+ this.requestProcessor.processMtbFile(mtbFile)
+
+ val requestCaptor = argumentCaptor<Request>()
+ verify(requestService, times(1)).save(requestCaptor.capture())
+ assertThat(requestCaptor.firstValue).isNotNull
+ assertThat(requestCaptor.firstValue.status).isEqualTo(RequestStatus.UNKNOWN)
+ assertThat(requestCaptor.firstValue.submissionType).isEqualTo(SubmissionType.FOLLOWUP)
+
+ val eventCaptor = argumentCaptor<ResponseEvent>()
+ verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture())
+ assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS)
+
+ val sendRequestCaptor = argumentCaptor<DnpmV2MtbFileRequest>()
+ verify(sender, times(1)).send(sendRequestCaptor.capture())
+ assertThat(sendRequestCaptor.firstValue.content.metadata.type).isEqualTo(MvhSubmissionType.FOLLOWUP)
+ }
+
+
+ @Test
+ fun testShouldSendFollowUpMtbFileAfterAdditionWasSent() {
+
+ // One failed attempt and one successful but not accepted
+ val lastRequests =
+ listOf(
+ Request(
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("initial"),
+ RequestType.MTB_FILE,
+ SubmissionType.INITIAL,
+ RequestStatus.ERROR,
+ Tan.empty(),
+ Instant.parse("2026-02-10T09:00:00Z"),
+ submissionAccepted = false,
+ ),
+ Request(
+ 2L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("initial"),
+ RequestType.MTB_FILE,
+ SubmissionType.INITIAL,
+ RequestStatus.WARNING,
+ Tan.empty(),
+ Instant.parse("2026-02-11T09:00:00Z"),
+ submissionAccepted = true,
+ ),
+ Request(
+ 3L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("initial"),
+ RequestType.MTB_FILE,
+ SubmissionType.ADDITION,
+ RequestStatus.WARNING,
+ Tan.empty(),
+ Instant.parse("2026-02-12T09:00:00Z"),
+ submissionAccepted = false,
+ )
+ )
+
+ doAnswer { lastRequests }
+ .whenever(requestService)
+ .allRequestsByPatientPseudonym(anyValueClass())
+
+ doAnswer { it.arguments[0] as String }
+ .whenever(pseudonymizeService)
+ .patientPseudonym(anyValueClass())
+
+ doAnswer { it.arguments[0] }.whenever(transformationService).transform(any<Mtb>())
+
+ doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) }
+ .whenever(sender)
+ .send(any<DnpmV2MtbFileRequest>())
+
+ whenever(consentProcessor.consentGatedCheckAndTryEmbedding(any())).thenReturn(true)
+
+ requestProcessor =
+ RequestProcessor(
+ pseudonymizeService,
+ transformationService,
+ sender,
+ requestService,
+ jsonMapper,
+ applicationEventPublisher,
+ AppConfigProperties(postInitialSubmissionBlock = true),
+ consentProcessor,
+ )
+
+ val mtbFile =
+ Mtb.builder()
+ .patient(Patient.builder().id("123").build())
+ .metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
+ .episodesOfCare(
+ listOf(
+ MtbEpisodeOfCare.builder()
+ .id("1")
+ .patient(Reference.builder().id("123").build())
+ .period(
+ PeriodDate.builder()
+ .start(Date.from(Instant.parse("2026-01-20T00:00:00.00Z")))
+ .build()
+ )
+ .build()
+ )
+ )
+ .followUps(
+ listOf(
+ FollowUp.builder()
+ .patient(Reference.builder().id("123").build())
+ .date(Date.from(Instant.parse("2026-05-21T00:00:00.00Z")))
+ .lastContactDate(Date.from(Instant.parse("2026-05-21T00:00:00.00Z")))
+ .build()
+ )
+ )
+ .build()
+
+ this.requestProcessor.processMtbFile(mtbFile)
+
+ val requestCaptor = argumentCaptor<Request>()
+ verify(requestService, times(1)).save(requestCaptor.capture())
+ assertThat(requestCaptor.firstValue).isNotNull
+ assertThat(requestCaptor.firstValue.status).isEqualTo(RequestStatus.UNKNOWN)
+ assertThat(requestCaptor.firstValue.submissionType).isEqualTo(SubmissionType.FOLLOWUP)
+
+ val eventCaptor = argumentCaptor<ResponseEvent>()
+ verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture())
+ assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS)
+
+ val sendRequestCaptor = argumentCaptor<DnpmV2MtbFileRequest>()
+ verify(sender, times(1)).send(sendRequestCaptor.capture())
+ assertThat(sendRequestCaptor.firstValue.content.metadata.type).isEqualTo(MvhSubmissionType.FOLLOWUP)
+ }
+ }
+
+ @Test
+ fun testShouldSendAdditionMtbFileAfterFollowUpWasSent() {
+
+ // One failed attempt and one successful but not accepted
+ val lastRequests =
+ listOf(
+ Request(
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("initial"),
+ RequestType.MTB_FILE,
+ SubmissionType.INITIAL,
+ RequestStatus.ERROR,
+ Tan.empty(),
+ Instant.parse("2026-02-10T09:00:00Z"),
+ submissionAccepted = false,
+ ),
+ Request(
+ 2L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("initial"),
+ RequestType.MTB_FILE,
+ SubmissionType.INITIAL,
+ RequestStatus.WARNING,
+ Tan.empty(),
+ Instant.parse("2026-02-11T09:00:00Z"),
+ submissionAccepted = true,
+ ),
+ Request(
+ 3L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("initial"),
+ RequestType.MTB_FILE,
+ SubmissionType.FOLLOWUP,
+ RequestStatus.WARNING,
+ Tan.empty(),
+ Instant.parse("2026-05-20T09:00:00Z"),
+ submissionAccepted = false,
+ )
+ )
+
+ doAnswer { lastRequests }
+ .whenever(requestService)
+ .allRequestsByPatientPseudonym(anyValueClass())
+
+ doAnswer { it.arguments[0] as String }
+ .whenever(pseudonymizeService)
+ .patientPseudonym(anyValueClass())
+
+ doAnswer { it.arguments[0] }.whenever(transformationService).transform(any<Mtb>())
+
+ doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) }
+ .whenever(sender)
+ .send(any<DnpmV2MtbFileRequest>())
+
+ whenever(consentProcessor.consentGatedCheckAndTryEmbedding(any())).thenReturn(true)
+
+ requestProcessor =
+ RequestProcessor(
+ pseudonymizeService,
+ transformationService,
+ sender,
+ requestService,
+ jsonMapper,
+ applicationEventPublisher,
+ AppConfigProperties(postInitialSubmissionBlock = true),
+ consentProcessor,
+ )
+
+ val mtbFile =
+ Mtb.builder()
+ .patient(Patient.builder().id("123").build())
+ .metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
+ .episodesOfCare(
+ listOf(
+ MtbEpisodeOfCare.builder()
+ .id("1")
+ .patient(Reference.builder().id("123").build())
+ .period(
+ PeriodDate.builder()
+ .start(Date.from(Instant.parse("2026-01-20T00:00:00.00Z")))
+ .build()
+ )
+ .build()
+ )
+ )
+ .followUps(
+ listOf(
+ FollowUp.builder()
+ .patient(Reference.builder().id("123").build())
+ .date(Date.from(Instant.parse("2026-05-20T00:00:00.00Z")))
+ .lastContactDate(Date.from(Instant.parse("2026-05-20T00:00:00.00Z")))
+ .build()
+ )
+ )
+ .build()
+
+ this.requestProcessor.processMtbFile(mtbFile)
+
+ val requestCaptor = argumentCaptor<Request>()
+ verify(requestService, times(1)).save(requestCaptor.capture())
+ assertThat(requestCaptor.firstValue).isNotNull
+ assertThat(requestCaptor.firstValue.status).isEqualTo(RequestStatus.UNKNOWN)
+ assertThat(requestCaptor.firstValue.submissionType).isEqualTo(SubmissionType.ADDITION)
+
+ val eventCaptor = argumentCaptor<ResponseEvent>()
+ verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture())
+ assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS)
+
+ val sendRequestCaptor = argumentCaptor<DnpmV2MtbFileRequest>()
+ verify(sender, times(1)).send(sendRequestCaptor.capture())
+ assertThat(sendRequestCaptor.firstValue.content.metadata.type).isEqualTo(MvhSubmissionType.ADDITION)
}
@Test