From 0e64b9a6ba69a829c231ab2c04c81d277d73f5c3 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Thu, 21 May 2026 16:57:26 +0200 Subject: feat: send submission as a follow-up (#290) This identifies follow-up submissions: * if the initial submission has been accepted * if the most recent follow-up is later than the patients last submission If these criteria are not met, the submission will be sent as an addition--- README.md | 16 + .../etl/processor/services/RequestProcessor.kt | 32 +- .../etl/processor/services/RequestProcessorTest.kt | 361 +++++++++++++++++++++ 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()) + + doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) } + .whenever(sender) + .send(any()) + + 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() + 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() + verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture()) + assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS) + + val sendRequestCaptor = argumentCaptor() + 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()) + + doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) } + .whenever(sender) + .send(any()) + + 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() + 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() + verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture()) + assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS) + + val sendRequestCaptor = argumentCaptor() + 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()) + + doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) } + .whenever(sender) + .send(any()) + + 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() + 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() + verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture()) + assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS) + + val sendRequestCaptor = argumentCaptor() + verify(sender, times(1)).send(sendRequestCaptor.capture()) + assertThat(sendRequestCaptor.firstValue.content.metadata.type).isEqualTo(MvhSubmissionType.ADDITION) } @Test -- cgit v1.2.3