Skip to content

Commit a7b8ac4

Browse files
authored
Merge pull request #13986 from SORMAS-Foundation/task-add_external_message_data_collision_popup
Implement external message data mismatch notification
2 parents 872d921 + 8fdc680 commit a7b8ac4

6 files changed

Lines changed: 402 additions & 3 deletions

File tree

sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/AbstractMessageProcessingFlowBase.java

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,11 +296,51 @@ protected FlowThen<ExternalMessageProcessingResult> doCaseSelectedFlow(
296296
(sampleReportIndex, previousSampleResult) -> createOneSampleAndPathogenTests(caze, sampleReportIndex, false, previousSampleResult);
297297

298298
FlowThen<ExternalMessageProcessingResult> caseFlow = flow.then(previousResult -> {
299-
ExternalMessageProcessingResult withCase = previousResult.getData().withSelectedCase(caze);
299+
CompletionStage<Boolean> mismatchInformationStage = CompletableFuture.completedFuture(true);
300300

301-
logger.debug("[MESSAGE PROCESSING] Continue processing with case: {}", withCase);
301+
// Check and inform about symptoms mismatch
302+
if (hasCaseSymptomsMismatch(caze, getExternalMessage())) {
303+
mismatchInformationStage = confirmCaseSymptomsMismatch(caze, getExternalMessage());
304+
}
305+
306+
// Chain hospitalization mismatch check
307+
mismatchInformationStage = mismatchInformationStage.thenCompose(confirmed -> {
308+
if (Boolean.TRUE.equals(confirmed) && hasCaseHospitalizationMismatch(caze, getExternalMessage())) {
309+
return confirmCaseHospitalizationMismatch(caze, getExternalMessage());
310+
} else {
311+
return CompletableFuture.completedFuture(confirmed);
312+
}
313+
});
314+
315+
// Chain exposures mismatch check
316+
mismatchInformationStage = mismatchInformationStage.thenCompose(confirmed -> {
317+
if (Boolean.TRUE.equals(confirmed) && hasCaseExposuresMismatch(caze, getExternalMessage())) {
318+
return confirmCaseExposuresMismatch(caze, getExternalMessage());
319+
} else {
320+
return CompletableFuture.completedFuture(confirmed);
321+
}
322+
});
302323

303-
return ProcessingResult.continueWith(withCase).asCompletedFuture();
324+
// Chain activities as case mismatch check
325+
mismatchInformationStage = mismatchInformationStage.thenCompose(confirmed -> {
326+
if (Boolean.TRUE.equals(confirmed) && hasCaseActivitiesAsCaseMismatch(caze, getExternalMessage())) {
327+
return confirmCaseActivitiesAsCaseMismatch(caze, getExternalMessage());
328+
} else {
329+
return CompletableFuture.completedFuture(confirmed);
330+
}
331+
});
332+
333+
return mismatchInformationStage.thenCompose(confirmed -> {
334+
ExternalMessageProcessingResult withCase = previousResult.getData().withSelectedCase(caze);
335+
336+
if (Boolean.TRUE.equals(confirmed)) {
337+
logger.debug("[MESSAGE PROCESSING] Continue processing with case: {}", withCase);
338+
return ProcessingResult.continueWith(withCase).asCompletedFuture();
339+
} else {
340+
logger.debug("[MESSAGE PROCESSING] Canceled processing with case: {} information mismatch aborted.", withCase);
341+
return ProcessingResult.withStatus(ProcessingResultStatus.CANCELED, previousResult.getData()).asCompletedFuture();
342+
}
343+
});
304344
});
305345
return caseFlow.then(
306346
previousResult -> doPickOrCreateSamplesFlow(
@@ -913,6 +953,38 @@ protected abstract void handleEditSample(
913953

914954
public abstract CompletionStage<Boolean> handleMultipleSampleConfirmation();
915955

956+
protected boolean hasCaseSymptomsMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
957+
return false;
958+
}
959+
960+
protected CompletionStage<Boolean> confirmCaseSymptomsMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
961+
return CompletableFuture.completedFuture(true);
962+
}
963+
964+
protected boolean hasCaseHospitalizationMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
965+
return false;
966+
}
967+
968+
protected CompletionStage<Boolean> confirmCaseHospitalizationMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
969+
return CompletableFuture.completedFuture(true);
970+
}
971+
972+
protected boolean hasCaseExposuresMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
973+
return false;
974+
}
975+
976+
protected CompletionStage<Boolean> confirmCaseExposuresMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
977+
return CompletableFuture.completedFuture(true);
978+
}
979+
980+
protected boolean hasCaseActivitiesAsCaseMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
981+
return false;
982+
}
983+
984+
protected CompletionStage<Boolean> confirmCaseActivitiesAsCaseMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
985+
return CompletableFuture.completedFuture(true);
986+
}
987+
916988
protected abstract void handleCreateSampleAndPathogenTests(
917989
SampleDto sample,
918990
List<PathogenTestDto> pathogenTests,

sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/doctordeclaration/AbstractDoctorDeclarationMessageProcessingFlow.java

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.ArrayList;
1919
import java.util.Date;
2020
import java.util.List;
21+
import java.util.Objects;
2122
import java.util.concurrent.CompletionStage;
2223
import java.util.function.BiFunction;
2324
import java.util.function.Consumer;
@@ -55,6 +56,7 @@
5556
import de.symeda.sormas.api.infrastructure.facility.FacilityType;
5657
import de.symeda.sormas.api.person.PersonDto;
5758
import de.symeda.sormas.api.sample.SampleSimilarityCriteria;
59+
import de.symeda.sormas.api.symptoms.SymptomsComparisonHelper;
5860
import de.symeda.sormas.api.symptoms.SymptomsDto;
5961
import de.symeda.sormas.api.user.UserDto;
6062
import de.symeda.sormas.api.utils.DataHelper;
@@ -285,6 +287,110 @@ protected void postBuildCaseSymptoms(CaseDataDto caseDto, ExternalMessageDto ext
285287
}
286288
}
287289

290+
@Override
291+
protected boolean hasCaseSymptomsMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
292+
boolean symptomsMismatch = SymptomsComparisonHelper.hasCaseSymptomsMismatch(caze.getSymptoms(), externalMessage.getCaseSymptoms());
293+
294+
if (symptomsMismatch) {
295+
logger.debug("[MESSAGE PROCESSING] Symptoms mismatch detected for existing case with UUID: {}", caze.getUuid());
296+
}
297+
298+
return symptomsMismatch;
299+
}
300+
301+
@Override
302+
protected boolean hasCaseHospitalizationMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
303+
// Check if there is hospitalization data in the external message
304+
if (externalMessage.getHospitalizationAdmissionDate() == null
305+
&& externalMessage.getHospitalizationDischargeDate() == null
306+
&& externalMessage.getAdmittedToHealthFacility() == null
307+
&& externalMessage.getHospitalizationFacilityName() == null
308+
&& externalMessage.getHospitalizationFacilityExternalId() == null) {
309+
return false;
310+
}
311+
312+
// Compare with existing hospitalization data
313+
HospitalizationDto existingHospitalization = caze.getHospitalization();
314+
if (existingHospitalization == null) {
315+
// If there's external hospitalization data but no existing hospitalization, it's a mismatch
316+
return true;
317+
}
318+
319+
// Check for differences in key hospitalization fields
320+
boolean admissionDateMismatch =
321+
!Objects.equals(existingHospitalization.getAdmissionDate(), externalMessage.getHospitalizationAdmissionDate());
322+
boolean dischargeDateMismatch =
323+
!Objects.equals(existingHospitalization.getDischargeDate(), externalMessage.getHospitalizationDischargeDate());
324+
boolean admittedMismatch =
325+
!Objects.equals(existingHospitalization.getAdmittedToHealthFacility(), externalMessage.getAdmittedToHealthFacility());
326+
327+
boolean hospitalizationFacilityNameMismatch =
328+
!Objects.equals(
329+
caze.getHealthFacility() != null ? caze.getHealthFacility().getCaption() : null,
330+
externalMessage.getHospitalizationFacilityName());
331+
boolean hospitalizationFacilityExternalIdMismatch =
332+
!Objects.equals(
333+
caze.getHealthFacility() != null ? caze.getHealthFacility().getExternalId() : null,
334+
externalMessage.getHospitalizationFacilityExternalId());
335+
336+
boolean mismatch = admissionDateMismatch || dischargeDateMismatch || admittedMismatch
337+
|| hospitalizationFacilityNameMismatch || hospitalizationFacilityExternalIdMismatch;
338+
339+
if (mismatch) {
340+
logger.debug("[MESSAGE PROCESSING] Hospitalization mismatch detected for existing case with UUID: {}", caze.getUuid());
341+
}
342+
343+
return mismatch;
344+
}
345+
346+
@Override
347+
protected boolean hasCaseExposuresMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
348+
// Check if there are exposures in the external message
349+
if (externalMessage.getExposures() == null || externalMessage.getExposures().isEmpty()) {
350+
return false;
351+
}
352+
353+
// Check if the case already has exposures
354+
EpiDataDto epiData = caze.getEpiData();
355+
if (epiData == null || epiData.getExposures() == null || epiData.getExposures().isEmpty()) {
356+
// If there are external exposures but no existing exposures, it's a mismatch
357+
return true;
358+
}
359+
360+
// If both have exposures, consider it a mismatch (user should be informed to review)
361+
boolean mismatch = true;
362+
363+
if (mismatch) {
364+
logger.debug("[MESSAGE PROCESSING] Exposures mismatch detected for existing case with UUID: {}", caze.getUuid());
365+
}
366+
367+
return mismatch;
368+
}
369+
370+
@Override
371+
protected boolean hasCaseActivitiesAsCaseMismatch(CaseDataDto caze, ExternalMessageDto externalMessage) {
372+
// Check if there are activities as case in the external message
373+
if (externalMessage.getActivitiesAsCase() == null || externalMessage.getActivitiesAsCase().isEmpty()) {
374+
return false;
375+
}
376+
377+
// Check if the case already has activities as case
378+
EpiDataDto epiData = caze.getEpiData();
379+
if (epiData == null || epiData.getActivitiesAsCase() == null || epiData.getActivitiesAsCase().isEmpty()) {
380+
// If there are external activities but no existing activities, it's a mismatch
381+
return true;
382+
}
383+
384+
// If both have activities, consider it a mismatch (user should be informed to review)
385+
boolean mismatch = true;
386+
387+
if (mismatch) {
388+
logger.debug("[MESSAGE PROCESSING] Activities as case mismatch detected for existing case with UUID: {}", caze.getUuid());
389+
}
390+
391+
return mismatch;
392+
}
393+
288394
/**
289395
* Sets the activities as case for the case from the external message, if present.
290396
* <p>

sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,12 @@ public interface Strings {
10661066
String infoExposurePeriodHeading = "infoExposurePeriodHeading";
10671067
String infoExposuresInfectionEnvironmentHint = "infoExposuresInfectionEnvironmentHint";
10681068
String infoExposuresRiskAreaHint = "infoExposuresRiskAreaHint";
1069+
String infoExternalMessageCaseActivitiesAsCaseMismatch = "infoExternalMessageCaseActivitiesAsCaseMismatch";
1070+
String infoExternalMessageCaseExposuresMismatch = "infoExternalMessageCaseExposuresMismatch";
1071+
String infoExternalMessageCaseHospitalizationMismatch = "infoExternalMessageCaseHospitalizationMismatch";
1072+
String infoExternalMessageCaseSymptomsMismatch = "infoExternalMessageCaseSymptomsMismatch";
1073+
String infoExternalMessageCaseSymptomsMismatchExistingCaseSymptoms = "infoExternalMessageCaseSymptomsMismatchExistingCaseSymptoms";
1074+
String infoExternalMessageCaseSymptomsMismatchExternalMessageSymptoms = "infoExternalMessageCaseSymptomsMismatchExternalMessageSymptoms";
10691075
String infoExternalMessageHospitalizationFacilityMissing = "infoExternalMessageHospitalizationFacilityMissing";
10701076
String infoExternalMessageHospitalizationMissingHospital = "infoExternalMessageHospitalizationMissingHospital";
10711077
String infoFacilityCsvImport = "infoFacilityCsvImport";
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*******************************************************************************
2+
* SORMAS® - Surveillance Outbreak Response Management & Analysis System
3+
* Copyright © 2016-2026 SORMAS Foundation gGmbH
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* GNU General Public License for more details.
12+
* You should have received a copy of the GNU General Public License
13+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
14+
*******************************************************************************/
15+
package de.symeda.sormas.api.symptoms;
16+
17+
import java.beans.IntrospectionException;
18+
import java.beans.Introspector;
19+
import java.beans.PropertyDescriptor;
20+
import java.lang.reflect.InvocationTargetException;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.TreeMap;
24+
25+
import org.apache.commons.lang3.StringUtils;
26+
27+
import de.symeda.sormas.api.EntityDto;
28+
import de.symeda.sormas.api.utils.pseudonymization.PseudonymizableDto;
29+
30+
/**
31+
* Helper class for comparing SymptomsDto objects.
32+
* Provides methods to extract comparable symptom values and check for mismatches between two SymptomsDto instances.
33+
*/
34+
public final class SymptomsComparisonHelper {
35+
36+
private static final List<String> NON_COMPARABLE_SYMPTOM_PROPERTIES =
37+
List.of(PseudonymizableDto.PSEUDONYMIZED, PseudonymizableDto.IN_JURISDICTION, SymptomsDto.SYMPTOMATIC);
38+
39+
private SymptomsComparisonHelper() {
40+
// Utility class
41+
}
42+
43+
/**
44+
* Checks if there is a mismatch between two SymptomsDto objects.
45+
*
46+
* @param symptomsA
47+
* The symptoms from the case
48+
* @param symptomsB
49+
* The symptoms from the external message
50+
* @return true if there is a mismatch between the two symptom objects, false otherwise
51+
*/
52+
public static boolean hasCaseSymptomsMismatch(SymptomsDto symptomsA, SymptomsDto symptomsB) {
53+
Map<String, Object> externalMessageSymptomsMap = getComparableSymptomsValues(symptomsB);
54+
if (externalMessageSymptomsMap.isEmpty()) {
55+
return false;
56+
}
57+
58+
Map<String, Object> caseSymptomsMap = getComparableSymptomsValues(symptomsA);
59+
for (Map.Entry<String, Object> entry : externalMessageSymptomsMap.entrySet()) {
60+
Object caseValue = caseSymptomsMap.get(entry.getKey());
61+
if (caseValue == null || !caseValue.equals(entry.getValue())) {
62+
return true;
63+
}
64+
}
65+
return false;
66+
}
67+
68+
/**
69+
* Extracts comparable values from a SymptomsDto object.
70+
* Uses reflection to get all property descriptors and filters out non-comparable properties.
71+
* String values are trimmed to null and excluded if empty.
72+
*
73+
* @param symptoms
74+
* The SymptomsDto object to extract values from
75+
* @return A TreeMap containing the comparable symptom property names and their values
76+
* @throws RuntimeException
77+
* if an exception occurs during introspection or property access
78+
*/
79+
private static Map<String, Object> getComparableSymptomsValues(SymptomsDto symptoms) {
80+
Map<String, Object> comparableValues = new TreeMap<>();
81+
if (symptoms == null) {
82+
return comparableValues;
83+
}
84+
85+
try {
86+
PropertyDescriptor[] propertyDescriptors = Introspector.getBeanInfo(SymptomsDto.class, EntityDto.class).getPropertyDescriptors();
87+
for (PropertyDescriptor propertyDescriptor : propertyDescriptors) {
88+
if (propertyDescriptor.getReadMethod() == null || NON_COMPARABLE_SYMPTOM_PROPERTIES.contains(propertyDescriptor.getName())) {
89+
continue;
90+
}
91+
92+
Object value = propertyDescriptor.getReadMethod().invoke(symptoms);
93+
if (value == null) {
94+
continue;
95+
}
96+
97+
if (value instanceof String) {
98+
value = StringUtils.trimToNull((String) value);
99+
if (value == null) {
100+
continue;
101+
}
102+
}
103+
104+
comparableValues.put(propertyDescriptor.getName(), value);
105+
}
106+
} catch (IntrospectionException | InvocationTargetException | IllegalAccessException e) {
107+
throw new RuntimeException("Exception when trying to compare symptoms: " + e.getMessage(), e);
108+
}
109+
110+
return comparableValues;
111+
}
112+
}

sormas-api/src/main/resources/strings.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,12 @@ infoPersonMergeSharedMustLead = Please note, you cannot choose this person as a
10751075
infoPickEventParticipantsForPersonMerge = The selected persons both have one event participant in at least one event. Merging the persons would lead to duplicate event participants.<br/> For each event, please select one of the event participants below as the leading event participant.<br/> 'Merge': The discarded event participant will be removed from the event. Its associated entities and information will be linked and/or added to the remaining event participant, but not overwritten. You will not be able to undo this action.<br/> 'Pick': The discarded event participant will be removed from the event. Its associated entities and information will not be linked and/or added to the remaining event participant. You will not be able to undo this action.
10761076
infoPickorMergeEventParticipantDuplicateEventParticipantByPersonByEvent = One of the selected persons has multiple active or archived event participants in the same event(s).<br/>Please review the events with the UUIDs presented below and delete duplicate event participants or ask a supervisor to do so in order to be able to merge these persons.
10771077
infoPersonMergeConfirmationForNonSimilarPersons = The two selected persons do not match the similarity requirements used by SORMAS to detect duplicate persons. Please ensure that the selected persons are indeed identical and supposed to be merged before proceeding.
1078+
infoExternalMessageCaseSymptomsMismatch = The symptoms in the doctor declaration differ from the selected case.<br/>The incoming values will not be transferred.<br/>Do you wish to continue?
1079+
infoExternalMessageCaseSymptomsMismatchExistingCaseSymptoms = Existing case symptoms: %s
1080+
infoExternalMessageCaseSymptomsMismatchExternalMessageSymptoms = External message symptoms: %s
1081+
infoExternalMessageCaseHospitalizationMismatch = The hospitalization information in the doctor declaration differs from the selected case.<br/>The incoming values will not be transferred.<br/>Do you wish to continue?
1082+
infoExternalMessageCaseExposuresMismatch = The exposures in the doctor declaration differ from the selected case.<br/>The incoming values will not be transferred.<br/>Do you wish to continue?
1083+
infoExternalMessageCaseActivitiesAsCaseMismatch = The activities as case in the doctor declaration differ from the selected case.<br/>The incoming values will not be transferred.<br/>Do you wish to continue?
10781084
infoHowToMergeCases = You can choose between two options when reviewing potentially duplicate cases:
10791085
infoHowToMergeContacts = You can choose between two options when reviewing potentially duplicate contacts:
10801086
infoCalculateCompleteness = The <i>Calculate Completeness</i> button on top can be used to calculate the completeness values for all cases in the table. This is only necessary if there are cases in the list that do not yet have a completeness value. Normally, this value is automatically updated whenever the details of a case or one of its contacts or samples change.

0 commit comments

Comments
 (0)